fix: Kanban board status flip-flopping and multi-location task deletion (#1387)
* auto-claude: subtask-1-1 - Update TASK_DELETE handler to delete from all locations Updated TASK_DELETE handler in crud-handlers.ts to use the findAllSpecPaths() pattern from project-store.ts. Previously it only deleted from a single location (task.specsPath), but tasks can exist in both main project and worktree directories. Changes: - Added import for getTaskWorktreeDir from worktree-paths - Added isValidTaskId() helper for path traversal protection - Added findAllSpecPaths() helper following ProjectStore pattern - Updated TASK_DELETE to iterate all spec locations and delete each - Improved error handling to continue with other locations if one fails This follows the archiveTasks() pattern which already handles this correctly. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-2-1 - Strengthen isInActivePhase guard and add terminal status protection - Strengthen isInActivePhase and isInTerminalPhase guards with explicit Boolean conversion - Add new TERMINAL_TASK_STATUSES array containing ['pr_created', 'done'] - Add isInTerminalStatus guard to prevent status recalculation for finalized workflow states - Update main condition to include !isInTerminalStatus as fourth guard - Update debug logging to include new isInTerminalStatus guard - This prevents stale plan file reads from incorrectly downgrading completed tasks Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-2-2 - Refactor determineTaskStatusAndReason() priority Restructured determineTaskStatusAndReason() in project-store.ts to check explicit plan.status values FIRST before calculating from subtasks. PRIORITY ORDER (to prevent status flip-flop during execution): 1. Terminal statuses (done, pr_created, error) - ALWAYS respected 2. Active process statuses (planning, coding, in_progress) - respected during execution 3. Explicit human_review with reviewReason - respected to prevent recalculation 4. QA report file status 5. Calculated status from subtask analysis (fallback only) This fixes the status flip-flop bug where calculated status would override explicit statuses during active task execution, causing erratic jumping between phases on the Kanban board. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-2-3 - Add debug logging to track status transitions - Add debug logging to updateTaskStatus() in task-store.ts to track status transitions including previous/new status and phase changes - Add debugLog import to project-store.ts (main process) - Add comprehensive logging to determineTaskStatusAndReason() covering: - Terminal status preservation (done, pr_created, error) - Active process status preservation (planning, coding, in_progress) - Explicit human_review and ai_review status preservation - QA report status detection - Fallback calculated status from subtask analysis - All logging gated behind DEBUG=true flag via debugLog utility * auto-claude: subtask-3-1 - Fix stale status after refresh by ensuring cache invalidation after file writes - TASK_REVIEW: Added persistPlanStatus() calls after writing QA report (approved) and QA fix request (rejected) to persist status to implementation_plan.json. Without this, the old status would be shown after page refresh. - TASK_RECOVER_STUCK: Added invalidateTasksCache() calls after all three atomicWriteFileSync() locations (completed task, incomplete task, restart). This ensures getTasks() returns fresh data reflecting the recovery. The pattern follows plan-file-utils.ts: UI updates immediately via IPC, then file persistence follows with cache invalidation after the write. * auto-claude: subtask-3-2 - Add forceRefresh option to task refresh flow - Add forceRefresh option to TASK_LIST IPC handler that invalidates cache before fetching - Update preload API and type declarations to support the option - Modify loadTasks() in task-store.ts to accept forceRefresh parameter - Update handleRefreshTasks() in App.tsx to pass forceRefresh: true This ensures the refresh button in KanbanBoard always fetches fresh data from disk instead of returning potentially stale cached data. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-4-1 - Add unit tests for multi-location deletion handler - Add tests for archiveTasks() multi-location behavior (main + worktree) - Add tests for unarchiveTasks() handling both main and worktree locations - Add path traversal protection tests for isValidTaskId - Add cache invalidation tests after archive operations - Add worktree deduplication tests for getTasks() - Fix related tests in ipc-bridge.test.ts and task-lifecycle.test.ts to expect the forceRefresh option parameter (from subtask-3-2) * auto-claude: subtask-4-2 - Add unit tests for updateTaskFromPlan() status stability Added comprehensive unit tests for the updateTaskFromPlan() function focusing on status stability during active execution and terminal phase protection: Active execution phase protection tests: - qa_review phase blocks status recalculation - qa_fixing phase blocks status recalculation - Subtasks still update even when status recalculation is blocked - Title still updates even when status recalculation is blocked Terminal transition blocking tests (shouldBlockTerminalTransition logic): - ai_review blocked when subtasks array is empty - ai_review allowed when all subtasks completed - human_review allowed when any subtask failed - in_progress transitions for partial completion Combined guard tests: - Terminal phase AND terminal status double protection - pr_created protection without terminal phase - Failed phase protection even with completed subtasks - Non-terminal status in non-active phase allows recalculation - Backlog protected during active planning Status stability edge cases: - Missing executionProgress handled gracefully - Undefined phase in executionProgress handled - reviewReason set to 'errors' when subtasks fail - reviewReason preserved when no failures Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: add auto-claude entries to .gitignore * fix: add 'error' to terminal task statuses for consistency - Aligns TERMINAL_TASK_STATUSES in task-store.ts with project-store.ts - Prevents status recalculation for tasks in error state - Maintains consistency across frontend and main process Co-Authored-By: Warp <agent@warp.dev> * fix: resolve CI TypeScript type errors for 'error' status - Add 'error' to TaskStatus type in task.ts - Add missing readdirSync and Dirent imports to crud-handlers.ts - Add type annotations to callback parameters in crud-handlers.ts - Add 'error' to TASK_STATUS_LABELS and TASK_STATUS_COLORS - Add 'error' translations to en/fr i18n files - Add 'error' to all TaskOrderState initializers in task-store.ts - Update getVisualColumn() to map 'error' to 'human_review' column - Add 'error' to test helpers in task-order.test.ts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: update test expectations to include 'error' in TaskOrderState The tests had hardcoded expected values for empty TaskOrderState that didn't include the new 'error' status. Updated all affected test expectations to include 'error: []'. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Warp <agent@warp.dev>
This commit is contained in:
@@ -171,4 +171,5 @@ OPUS_ANALYSIS_AND_IDEAS.md
|
||||
# Auto Claude generated files
|
||||
.security-key
|
||||
/shared_docs
|
||||
logs/security/
|
||||
Agents.md
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../../../../../apps/frontend/node_modules
|
||||
@@ -108,7 +108,8 @@ describe('IPC Bridge Integration', () => {
|
||||
const getTasks = electronAPI['getTasks'] as (projectId: string) => Promise<unknown>;
|
||||
await getTasks('project-id');
|
||||
|
||||
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith('task:list', 'project-id');
|
||||
// Second argument is optional options (undefined when not provided)
|
||||
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith('task:list', 'project-id', undefined);
|
||||
});
|
||||
|
||||
it('should have createTask method', async () => {
|
||||
|
||||
@@ -148,8 +148,8 @@ describe('Task Lifecycle Integration', () => {
|
||||
const getTasks = electronAPI['getTasks'] as (projectId: string) => Promise<unknown>;
|
||||
const result = await getTasks('project-id');
|
||||
|
||||
// Verify IPC invocation
|
||||
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith('task:list', 'project-id');
|
||||
// Verify IPC invocation - second argument is optional options (undefined when not provided)
|
||||
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith('task:list', 'project-id', undefined);
|
||||
|
||||
// Verify task data includes plan with subtasks
|
||||
expect(result).toMatchObject({
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
* Tests project CRUD operations and task reading from filesystem
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'fs';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
|
||||
// Test directories
|
||||
const TEST_DIR = '/tmp/project-store-test';
|
||||
const USER_DATA_PATH = path.join(TEST_DIR, 'userData');
|
||||
const TEST_PROJECT_PATH = path.join(TEST_DIR, 'test-project');
|
||||
// Test directories - will be set in beforeEach with unique temp dir
|
||||
let TEST_DIR: string;
|
||||
let USER_DATA_PATH: string;
|
||||
let TEST_PROJECT_PATH: string;
|
||||
|
||||
// Mock Electron before importing the store
|
||||
vi.mock('electron', () => ({
|
||||
@@ -21,8 +22,13 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
// Setup test directories
|
||||
// Setup test directories with unique secure temp dir
|
||||
function setupTestDirs(): void {
|
||||
// Create a unique, secure temporary directory
|
||||
TEST_DIR = mkdtempSync(path.join(tmpdir(), 'project-store-test-'));
|
||||
USER_DATA_PATH = path.join(TEST_DIR, 'userData');
|
||||
TEST_PROJECT_PATH = path.join(TEST_DIR, 'test-project');
|
||||
|
||||
mkdirSync(USER_DATA_PATH, { recursive: true });
|
||||
mkdirSync(path.join(USER_DATA_PATH, 'store'), { recursive: true });
|
||||
mkdirSync(TEST_PROJECT_PATH, { recursive: true });
|
||||
@@ -585,4 +591,381 @@ describe('ProjectStore', () => {
|
||||
expect(projects).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('archiveTasks - multi-location handling', () => {
|
||||
it('should archive task from main specs directory only', async () => {
|
||||
// Create spec directory in main location only
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '001-test-task');
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
|
||||
const plan = {
|
||||
feature: 'Test Feature',
|
||||
workflow_type: 'feature',
|
||||
services_involved: [],
|
||||
phases: [],
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
spec_file: 'spec.md'
|
||||
};
|
||||
writeFileSync(path.join(specsDir, 'implementation_plan.json'), JSON.stringify(plan));
|
||||
|
||||
const { ProjectStore } = await import('../project-store');
|
||||
const store = new ProjectStore();
|
||||
|
||||
const project = store.addProject(TEST_PROJECT_PATH);
|
||||
const result = store.archiveTasks(project.id, ['001-test-task'], '1.0.0');
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify metadata was created with archive info
|
||||
const metadataPath = path.join(specsDir, 'task_metadata.json');
|
||||
expect(existsSync(metadataPath)).toBe(true);
|
||||
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8'));
|
||||
expect(metadata.archivedAt).toBeDefined();
|
||||
expect(metadata.archivedInVersion).toBe('1.0.0');
|
||||
});
|
||||
|
||||
it('should archive task from BOTH main and worktree locations', async () => {
|
||||
// Create spec directory in main location
|
||||
const mainSpecsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '002-multi-location');
|
||||
mkdirSync(mainSpecsDir, { recursive: true });
|
||||
|
||||
// Create spec directory in worktree location
|
||||
// Worktree path: .auto-claude/worktrees/tasks/<worktreeName>/.auto-claude/specs/<taskId>
|
||||
const worktreeDir = path.join(
|
||||
TEST_PROJECT_PATH,
|
||||
'.auto-claude',
|
||||
'worktrees',
|
||||
'tasks',
|
||||
'my-worktree',
|
||||
'.auto-claude',
|
||||
'specs',
|
||||
'002-multi-location'
|
||||
);
|
||||
mkdirSync(worktreeDir, { recursive: true });
|
||||
|
||||
const plan = {
|
||||
feature: 'Multi-Location Feature',
|
||||
workflow_type: 'feature',
|
||||
services_involved: [],
|
||||
phases: [],
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
spec_file: 'spec.md'
|
||||
};
|
||||
|
||||
writeFileSync(path.join(mainSpecsDir, 'implementation_plan.json'), JSON.stringify(plan));
|
||||
writeFileSync(path.join(worktreeDir, 'implementation_plan.json'), JSON.stringify(plan));
|
||||
|
||||
const { ProjectStore } = await import('../project-store');
|
||||
const store = new ProjectStore();
|
||||
|
||||
const project = store.addProject(TEST_PROJECT_PATH);
|
||||
const result = store.archiveTasks(project.id, ['002-multi-location'], '2.0.0');
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify metadata was created in BOTH locations
|
||||
const mainMetadataPath = path.join(mainSpecsDir, 'task_metadata.json');
|
||||
const worktreeMetadataPath = path.join(worktreeDir, 'task_metadata.json');
|
||||
|
||||
expect(existsSync(mainMetadataPath)).toBe(true);
|
||||
expect(existsSync(worktreeMetadataPath)).toBe(true);
|
||||
|
||||
const mainMetadata = JSON.parse(readFileSync(mainMetadataPath, 'utf-8'));
|
||||
const worktreeMetadata = JSON.parse(readFileSync(worktreeMetadataPath, 'utf-8'));
|
||||
|
||||
expect(mainMetadata.archivedAt).toBeDefined();
|
||||
expect(mainMetadata.archivedInVersion).toBe('2.0.0');
|
||||
expect(worktreeMetadata.archivedAt).toBeDefined();
|
||||
expect(worktreeMetadata.archivedInVersion).toBe('2.0.0');
|
||||
});
|
||||
|
||||
it('should handle task that exists only in worktree', async () => {
|
||||
// Create spec directory ONLY in worktree location (not in main)
|
||||
const worktreeDir = path.join(
|
||||
TEST_PROJECT_PATH,
|
||||
'.auto-claude',
|
||||
'worktrees',
|
||||
'tasks',
|
||||
'only-worktree',
|
||||
'.auto-claude',
|
||||
'specs',
|
||||
'003-worktree-only'
|
||||
);
|
||||
mkdirSync(worktreeDir, { recursive: true });
|
||||
|
||||
const plan = {
|
||||
feature: 'Worktree Only Feature',
|
||||
workflow_type: 'feature',
|
||||
services_involved: [],
|
||||
phases: [],
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
spec_file: 'spec.md'
|
||||
};
|
||||
writeFileSync(path.join(worktreeDir, 'implementation_plan.json'), JSON.stringify(plan));
|
||||
|
||||
const { ProjectStore } = await import('../project-store');
|
||||
const store = new ProjectStore();
|
||||
|
||||
const project = store.addProject(TEST_PROJECT_PATH);
|
||||
const result = store.archiveTasks(project.id, ['003-worktree-only'], '1.0.0');
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify metadata was created in worktree
|
||||
const metadataPath = path.join(worktreeDir, 'task_metadata.json');
|
||||
expect(existsSync(metadataPath)).toBe(true);
|
||||
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8'));
|
||||
expect(metadata.archivedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should skip non-existent task gracefully', async () => {
|
||||
const { ProjectStore } = await import('../project-store');
|
||||
const store = new ProjectStore();
|
||||
|
||||
// Create .auto-claude directory so project is recognized
|
||||
mkdirSync(path.join(TEST_PROJECT_PATH, '.auto-claude'), { recursive: true });
|
||||
|
||||
const project = store.addProject(TEST_PROJECT_PATH);
|
||||
// Task doesn't exist anywhere
|
||||
const result = store.archiveTasks(project.id, ['nonexistent-task']);
|
||||
|
||||
// Should return true (no errors) since missing tasks are skipped
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject path traversal attempts in taskId', async () => {
|
||||
// Create a valid spec dir
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', 'valid-task');
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
|
||||
const plan = { feature: 'Test', phases: [] };
|
||||
writeFileSync(path.join(specsDir, 'implementation_plan.json'), JSON.stringify(plan));
|
||||
|
||||
const { ProjectStore } = await import('../project-store');
|
||||
const store = new ProjectStore();
|
||||
|
||||
const project = store.addProject(TEST_PROJECT_PATH);
|
||||
|
||||
// Try various path traversal attacks
|
||||
const maliciousIds = [
|
||||
'../../../etc/passwd',
|
||||
'..\\..\\windows\\system32',
|
||||
'task/../../../secret',
|
||||
'.',
|
||||
'..',
|
||||
'task\0.json'
|
||||
];
|
||||
|
||||
for (const maliciousId of maliciousIds) {
|
||||
// These should be rejected and not cause any file operations
|
||||
const result = store.archiveTasks(project.id, [maliciousId]);
|
||||
// Should return true since invalid IDs are skipped, not treated as errors
|
||||
expect(result).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return false for non-existent project', async () => {
|
||||
const { ProjectStore } = await import('../project-store');
|
||||
const store = new ProjectStore();
|
||||
|
||||
const result = store.archiveTasks('nonexistent-project-id', ['some-task']);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unarchiveTasks - multi-location handling', () => {
|
||||
it('should unarchive task from BOTH main and worktree locations', async () => {
|
||||
// Create archived task in both locations
|
||||
const mainSpecsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '004-unarchive-test');
|
||||
mkdirSync(mainSpecsDir, { recursive: true });
|
||||
|
||||
const worktreeDir = path.join(
|
||||
TEST_PROJECT_PATH,
|
||||
'.auto-claude',
|
||||
'worktrees',
|
||||
'tasks',
|
||||
'unarchive-worktree',
|
||||
'.auto-claude',
|
||||
'specs',
|
||||
'004-unarchive-test'
|
||||
);
|
||||
mkdirSync(worktreeDir, { recursive: true });
|
||||
|
||||
const plan = {
|
||||
feature: 'Unarchive Test',
|
||||
phases: [],
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z'
|
||||
};
|
||||
|
||||
const archivedMetadata = {
|
||||
archivedAt: '2024-06-01T00:00:00Z',
|
||||
archivedInVersion: '1.0.0'
|
||||
};
|
||||
|
||||
// Create plan and archived metadata in both locations
|
||||
writeFileSync(path.join(mainSpecsDir, 'implementation_plan.json'), JSON.stringify(plan));
|
||||
writeFileSync(path.join(mainSpecsDir, 'task_metadata.json'), JSON.stringify(archivedMetadata));
|
||||
writeFileSync(path.join(worktreeDir, 'implementation_plan.json'), JSON.stringify(plan));
|
||||
writeFileSync(path.join(worktreeDir, 'task_metadata.json'), JSON.stringify(archivedMetadata));
|
||||
|
||||
const { ProjectStore } = await import('../project-store');
|
||||
const store = new ProjectStore();
|
||||
|
||||
const project = store.addProject(TEST_PROJECT_PATH);
|
||||
const result = store.unarchiveTasks(project.id, ['004-unarchive-test']);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify archivedAt was removed from BOTH locations
|
||||
const mainMetadata = JSON.parse(readFileSync(path.join(mainSpecsDir, 'task_metadata.json'), 'utf-8'));
|
||||
const worktreeMetadata = JSON.parse(readFileSync(path.join(worktreeDir, 'task_metadata.json'), 'utf-8'));
|
||||
|
||||
expect(mainMetadata.archivedAt).toBeUndefined();
|
||||
expect(mainMetadata.archivedInVersion).toBeUndefined();
|
||||
expect(worktreeMetadata.archivedAt).toBeUndefined();
|
||||
expect(worktreeMetadata.archivedInVersion).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache invalidation', () => {
|
||||
it('should invalidate cache after archiveTasks', async () => {
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '005-cache-test');
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
|
||||
const plan = {
|
||||
feature: 'Cache Test Feature',
|
||||
workflow_type: 'feature',
|
||||
services_involved: [],
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [{ id: 'subtask-1', description: 'Test', status: 'pending' }]
|
||||
}
|
||||
],
|
||||
final_acceptance: [],
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
spec_file: 'spec.md'
|
||||
};
|
||||
writeFileSync(path.join(specsDir, 'implementation_plan.json'), JSON.stringify(plan));
|
||||
|
||||
const { ProjectStore } = await import('../project-store');
|
||||
const store = new ProjectStore();
|
||||
|
||||
const project = store.addProject(TEST_PROJECT_PATH);
|
||||
|
||||
// First call should populate cache
|
||||
const tasksBefore = store.getTasks(project.id);
|
||||
expect(tasksBefore).toHaveLength(1);
|
||||
expect(tasksBefore[0].metadata?.archivedAt).toBeUndefined();
|
||||
|
||||
// Archive the task
|
||||
store.archiveTasks(project.id, ['005-cache-test']);
|
||||
|
||||
// After archiving, cache should be invalidated and getTasks should return updated data
|
||||
const tasksAfter = store.getTasks(project.id);
|
||||
expect(tasksAfter[0].metadata?.archivedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return fresh data after invalidateTasksCache is called', async () => {
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '006-invalidate-test');
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
|
||||
const plan = {
|
||||
feature: 'Initial Feature',
|
||||
workflow_type: 'feature',
|
||||
services_involved: [],
|
||||
phases: [],
|
||||
final_acceptance: [],
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
spec_file: 'spec.md'
|
||||
};
|
||||
writeFileSync(path.join(specsDir, 'implementation_plan.json'), JSON.stringify(plan));
|
||||
|
||||
const { ProjectStore } = await import('../project-store');
|
||||
const store = new ProjectStore();
|
||||
|
||||
const project = store.addProject(TEST_PROJECT_PATH);
|
||||
|
||||
// First call should populate cache
|
||||
const tasksBefore = store.getTasks(project.id);
|
||||
expect(tasksBefore[0].title).toBe('Initial Feature');
|
||||
|
||||
// Modify the file directly (simulating external change)
|
||||
const updatedPlan = { ...plan, feature: 'Updated Feature' };
|
||||
writeFileSync(path.join(specsDir, 'implementation_plan.json'), JSON.stringify(updatedPlan));
|
||||
|
||||
// Without invalidation, should still return cached data
|
||||
const tasksCached = store.getTasks(project.id);
|
||||
expect(tasksCached[0].title).toBe('Initial Feature');
|
||||
|
||||
// Invalidate cache
|
||||
store.invalidateTasksCache(project.id);
|
||||
|
||||
// Now should return fresh data
|
||||
const tasksAfterInvalidation = store.getTasks(project.id);
|
||||
expect(tasksAfterInvalidation[0].title).toBe('Updated Feature');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTasks - worktree deduplication', () => {
|
||||
it('should not duplicate tasks that exist in both main and worktree', async () => {
|
||||
// Create same task in both main and worktree
|
||||
const mainSpecsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '007-dedupe-test');
|
||||
mkdirSync(mainSpecsDir, { recursive: true });
|
||||
|
||||
const worktreeDir = path.join(
|
||||
TEST_PROJECT_PATH,
|
||||
'.auto-claude',
|
||||
'worktrees',
|
||||
'tasks',
|
||||
'dedupe-worktree',
|
||||
'.auto-claude',
|
||||
'specs',
|
||||
'007-dedupe-test'
|
||||
);
|
||||
mkdirSync(worktreeDir, { recursive: true });
|
||||
|
||||
const plan = {
|
||||
feature: 'Dedupe Test Feature',
|
||||
workflow_type: 'feature',
|
||||
services_involved: [],
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [{ id: 'subtask-1', description: 'Test', status: 'pending' }]
|
||||
}
|
||||
],
|
||||
final_acceptance: [],
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
spec_file: 'spec.md'
|
||||
};
|
||||
|
||||
writeFileSync(path.join(mainSpecsDir, 'implementation_plan.json'), JSON.stringify(plan));
|
||||
writeFileSync(path.join(worktreeDir, 'implementation_plan.json'), JSON.stringify(plan));
|
||||
|
||||
const { ProjectStore } = await import('../project-store');
|
||||
const store = new ProjectStore();
|
||||
|
||||
const project = store.addProject(TEST_PROJECT_PATH);
|
||||
const tasks = store.getTasks(project.id);
|
||||
|
||||
// Should only return ONE task, not two
|
||||
const matchingTasks = tasks.filter(t => t.specId === '007-dedupe-test');
|
||||
expect(matchingTasks).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,12 @@ import { ipcMain } from 'electron';
|
||||
import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/constants';
|
||||
import type { IPCResult, Task, TaskMetadata } from '../../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync } from 'fs';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, Dirent } from 'fs';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { titleGenerator } from '../../title-generator';
|
||||
import { AgentManager } from '../../agent';
|
||||
import { findTaskAndProject } from './shared';
|
||||
import { findAllSpecPaths } from '../../utils/spec-path-helpers';
|
||||
|
||||
/**
|
||||
* Register task CRUD (Create, Read, Update, Delete) handlers
|
||||
@@ -14,11 +15,22 @@ import { findTaskAndProject } from './shared';
|
||||
export function registerTaskCRUDHandlers(agentManager: AgentManager): void {
|
||||
/**
|
||||
* List all tasks for a project
|
||||
* @param projectId - The project ID to fetch tasks for
|
||||
* @param options - Optional parameters
|
||||
* @param options.forceRefresh - If true, invalidates cache before fetching (for refresh button)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TASK_LIST,
|
||||
async (_, projectId: string): Promise<IPCResult<Task[]>> => {
|
||||
console.warn('[IPC] TASK_LIST called with projectId:', projectId);
|
||||
async (_, projectId: string, options?: { forceRefresh?: boolean }): Promise<IPCResult<Task[]>> => {
|
||||
console.warn('[IPC] TASK_LIST called with projectId:', projectId, 'options:', options);
|
||||
|
||||
// If forceRefresh is requested, invalidate cache first
|
||||
// This ensures the refresh button always returns fresh data from disk
|
||||
if (options?.forceRefresh) {
|
||||
projectStore.invalidateTasksCache(projectId);
|
||||
console.warn('[IPC] TASK_LIST cache invalidated for forceRefresh');
|
||||
}
|
||||
|
||||
const tasks = projectStore.getTasks(projectId);
|
||||
console.warn('[IPC] TASK_LIST returning', tasks.length, 'tasks');
|
||||
return { success: true, data: tasks };
|
||||
@@ -73,16 +85,16 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void {
|
||||
let specNumber = 1;
|
||||
if (existsSync(specsDir)) {
|
||||
const existingDirs = readdirSync(specsDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name);
|
||||
.filter((d: Dirent) => d.isDirectory())
|
||||
.map((d: Dirent) => d.name);
|
||||
|
||||
// Extract numbers from spec directory names (e.g., "001-feature" -> 1)
|
||||
const existingNumbers = existingDirs
|
||||
.map(name => {
|
||||
.map((name: string) => {
|
||||
const match = name.match(/^(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
})
|
||||
.filter(n => n > 0);
|
||||
.filter((n: number) => n > 0);
|
||||
|
||||
if (existingNumbers.length > 0) {
|
||||
specNumber = Math.max(...existingNumbers) + 1;
|
||||
@@ -222,29 +234,47 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void {
|
||||
return { success: false, error: 'Cannot delete a running task. Stop the task first.' };
|
||||
}
|
||||
|
||||
// Delete the spec directory - use task.specsPath if available (handles worktree tasks)
|
||||
const specDir = task.specsPath || path.join(project.path, getSpecsDir(project.autoBuildPath), task.specId);
|
||||
// Find ALL locations where this task exists (main + worktrees)
|
||||
// Following the archiveTasks() pattern from project-store.ts
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specPaths = findAllSpecPaths(project.path, specsBaseDir, task.specId);
|
||||
|
||||
try {
|
||||
console.warn(`[TASK_DELETE] Attempting to delete: ${specDir} (location: ${task.location || 'unknown'})`);
|
||||
if (existsSync(specDir)) {
|
||||
// If spec directory doesn't exist anywhere, return success (already removed)
|
||||
if (specPaths.length === 0) {
|
||||
console.warn(`[TASK_DELETE] No spec directories found for task ${taskId} - already removed`);
|
||||
projectStore.invalidateTasksCache(project.id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
let hasErrors = false;
|
||||
const errors: string[] = [];
|
||||
|
||||
// Delete from ALL locations
|
||||
for (const specDir of specPaths) {
|
||||
try {
|
||||
console.warn(`[TASK_DELETE] Attempting to delete: ${specDir}`);
|
||||
await rm(specDir, { recursive: true, force: true });
|
||||
console.warn(`[TASK_DELETE] Deleted spec directory: ${specDir}`);
|
||||
} else {
|
||||
console.warn(`[TASK_DELETE] Spec directory not found: ${specDir}`);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error(`[TASK_DELETE] Error deleting spec directory ${specDir}:`, error);
|
||||
hasErrors = true;
|
||||
errors.push(`${specDir}: ${errorMsg}`);
|
||||
// Continue with other locations even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate cache since a task was deleted
|
||||
projectStore.invalidateTasksCache(project.id);
|
||||
// Invalidate cache since a task was deleted
|
||||
projectStore.invalidateTasksCache(project.id);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[TASK_DELETE] Error deleting spec directory:', error);
|
||||
if (hasErrors) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete task files'
|
||||
error: `Failed to delete some task files: ${errors.join('; ')}`
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -391,6 +391,7 @@ export function registerTaskExecutionHandlers(
|
||||
return { success: false, error: 'Failed to write QA report file' };
|
||||
}
|
||||
|
||||
// Notify UI immediately for instant feedback
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(
|
||||
@@ -399,6 +400,20 @@ export function registerTaskExecutionHandlers(
|
||||
'done'
|
||||
);
|
||||
}
|
||||
|
||||
// CRITICAL: Persist 'done' status to implementation_plan.json
|
||||
// Without this, the old status would be shown after page refresh since
|
||||
// getTasks() reads status from the plan file, not from the Zustand store.
|
||||
const planPath = getPlanPath(project, task);
|
||||
try {
|
||||
const persisted = await persistPlanStatus(planPath, 'done', project.id);
|
||||
if (persisted) {
|
||||
console.warn('[TASK_REVIEW] Persisted approved status (done) to implementation_plan.json');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[TASK_REVIEW] Failed to persist approved status:', err);
|
||||
// Non-fatal: UI already updated, file persistence is best-effort
|
||||
}
|
||||
} else {
|
||||
// Reset and discard all changes from worktree merge in main
|
||||
// The worktree still has all changes, so nothing is lost
|
||||
@@ -519,6 +534,7 @@ export function registerTaskExecutionHandlers(
|
||||
console.warn('[TASK_REVIEW] Starting QA process with projectPath:', qaProjectPath);
|
||||
agentManager.startQAProcess(taskId, qaProjectPath, task.specId);
|
||||
|
||||
// Notify UI immediately for instant feedback
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(
|
||||
@@ -527,6 +543,20 @@ export function registerTaskExecutionHandlers(
|
||||
'in_progress'
|
||||
);
|
||||
}
|
||||
|
||||
// CRITICAL: Persist 'in_progress' status to implementation_plan.json
|
||||
// Without this, the old status (e.g., 'human_review') would be shown after page refresh
|
||||
// since getTasks() reads status from the plan file, not from the Zustand store.
|
||||
const planPath = getPlanPath(project, task);
|
||||
try {
|
||||
const persisted = await persistPlanStatus(planPath, 'in_progress', project.id);
|
||||
if (persisted) {
|
||||
console.warn('[TASK_REVIEW] Persisted rejected status (in_progress) to implementation_plan.json');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[TASK_REVIEW] Failed to persist rejected status:', err);
|
||||
// Non-fatal: UI already updated, file persistence is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
@@ -964,6 +994,10 @@ export function registerTaskExecutionHandlers(
|
||||
};
|
||||
}
|
||||
|
||||
// CRITICAL: Invalidate cache AFTER file writes complete
|
||||
// This ensures getTasks() returns fresh data reflecting the recovery
|
||||
projectStore.invalidateTasksCache(project.id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -1025,6 +1059,10 @@ export function registerTaskExecutionHandlers(
|
||||
error: 'Failed to write plan file during recovery'
|
||||
};
|
||||
}
|
||||
|
||||
// CRITICAL: Invalidate cache AFTER file writes complete
|
||||
// This ensures getTasks() returns fresh data reflecting the recovery
|
||||
projectStore.invalidateTasksCache(project.id);
|
||||
}
|
||||
|
||||
// Stop file watcher if it was watching this task
|
||||
@@ -1101,6 +1139,10 @@ export function registerTaskExecutionHandlers(
|
||||
// The plan status will be updated by the agent when it starts
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Invalidate cache AFTER file writes complete
|
||||
// This ensures getTasks() returns fresh data reflecting the restart status
|
||||
projectStore.invalidateTasksCache(project.id);
|
||||
}
|
||||
|
||||
// Start the task execution
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, Implemen
|
||||
import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir, JSON_ERROR_PREFIX, JSON_ERROR_TITLE_SUFFIX } from '../shared/constants';
|
||||
import { getAutoBuildPath, isInitialized } from './project-initializer';
|
||||
import { getTaskWorktreeDir } from './worktree-paths';
|
||||
import { debugLog } from '../shared/utils/debug-logger';
|
||||
import { isValidTaskId, findAllSpecPaths } from './utils/spec-path-helpers';
|
||||
|
||||
interface TabState {
|
||||
openProjectIds: string[];
|
||||
@@ -255,17 +257,17 @@ export class ProjectStore {
|
||||
return cached.tasks;
|
||||
}
|
||||
|
||||
console.warn('[ProjectStore] getTasks called - will load from disk', {
|
||||
debugLog('[ProjectStore] getTasks called - will load from disk', {
|
||||
projectId,
|
||||
reason: cached ? 'cache expired' : 'cache miss',
|
||||
cacheAge: cached ? now - cached.timestamp : 'N/A'
|
||||
});
|
||||
const project = this.getProject(projectId);
|
||||
if (!project) {
|
||||
console.warn('[ProjectStore] Project not found for id:', projectId);
|
||||
debugLog('[ProjectStore] Project not found for id:', projectId);
|
||||
return [];
|
||||
}
|
||||
console.warn('[ProjectStore] Found project:', project.name, 'autoBuildPath:', project.autoBuildPath, 'path:', project.path);
|
||||
debugLog('[ProjectStore] Found project:', project.name, 'autoBuildPath:', project.autoBuildPath, 'path:', project.path);
|
||||
|
||||
const allTasks: Task[] = [];
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
@@ -536,13 +538,18 @@ export class ProjectStore {
|
||||
/**
|
||||
* Determine task status and review reason based on plan and files.
|
||||
*
|
||||
* This method calculates the correct status from subtask progress and QA state,
|
||||
* providing backwards compatibility for existing tasks with incorrect status.
|
||||
* PRIORITY ORDER (to prevent status flip-flop during execution):
|
||||
* 1. Terminal statuses (done, pr_created, error) - ALWAYS respected
|
||||
* 2. Active process statuses (planning, coding, in_progress) - respected during execution
|
||||
* 3. Explicit human_review with reviewReason - respected to prevent recalculation
|
||||
* 4. QA report file status
|
||||
* 5. Calculated status from subtask analysis (fallback only)
|
||||
*
|
||||
* Review reasons:
|
||||
* - 'completed': All subtasks done, QA passed - ready for merge
|
||||
* - 'errors': Subtasks failed during execution - needs attention
|
||||
* - 'qa_rejected': QA found issues that need fixing
|
||||
* - 'plan_review': Spec creation complete, awaiting user approval
|
||||
*/
|
||||
private determineTaskStatusAndReason(
|
||||
plan: ImplementationPlan | null,
|
||||
@@ -552,6 +559,135 @@ export class ProjectStore {
|
||||
// Handle both 'subtasks' and 'chunks' naming conventions, filter out undefined
|
||||
const allSubtasks = plan?.phases?.flatMap((p) => p.subtasks || (p as { chunks?: PlanSubtask[] }).chunks || []).filter(Boolean) || [];
|
||||
|
||||
// Status mapping from plan.status values to TaskStatus
|
||||
const statusMap: Record<string, TaskStatus> = {
|
||||
'pending': 'backlog',
|
||||
'planning': 'in_progress',
|
||||
'in_progress': 'in_progress',
|
||||
'coding': 'in_progress',
|
||||
'review': 'ai_review',
|
||||
'completed': 'done',
|
||||
'done': 'done',
|
||||
'human_review': 'human_review',
|
||||
'ai_review': 'ai_review',
|
||||
'pr_created': 'pr_created',
|
||||
'backlog': 'backlog',
|
||||
'error': 'error'
|
||||
};
|
||||
|
||||
// Terminal statuses that should NEVER be overridden by calculation
|
||||
const TERMINAL_STATUSES = new Set<TaskStatus>(['done', 'pr_created', 'error']);
|
||||
|
||||
// ========================================================================
|
||||
// STEP 1: Check for terminal statuses (highest priority - always respected)
|
||||
// ========================================================================
|
||||
if (plan?.status) {
|
||||
const storedStatus = statusMap[plan.status];
|
||||
if (storedStatus && TERMINAL_STATUSES.has(storedStatus)) {
|
||||
debugLog('[determineTaskStatusAndReason] Terminal status respected:', {
|
||||
planStatus: plan.status,
|
||||
mappedStatus: storedStatus,
|
||||
reason: 'Terminal statuses (done, pr_created, error) are never overridden'
|
||||
});
|
||||
return { status: storedStatus };
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// STEP 2: Check for active process statuses during execution
|
||||
// These prevent status flip-flop while backend is actively running
|
||||
// ========================================================================
|
||||
if (plan?.status) {
|
||||
const storedStatus = statusMap[plan.status];
|
||||
const rawStatus = plan.status as string;
|
||||
const isActiveProcessStatus = rawStatus === 'planning' || rawStatus === 'coding' || rawStatus === 'in_progress';
|
||||
|
||||
// Check if this is a plan review stage (spec creation complete, awaiting approval)
|
||||
const isPlanReviewStage = (plan as unknown as { planStatus?: string })?.planStatus === 'review';
|
||||
|
||||
// During active execution, respect the stored status to prevent jumping
|
||||
if (isActiveProcessStatus && storedStatus === 'in_progress') {
|
||||
debugLog('[determineTaskStatusAndReason] Active process status preserved:', {
|
||||
planStatus: plan.status,
|
||||
mappedStatus: storedStatus,
|
||||
reason: 'Execution in progress - status recalculation blocked'
|
||||
});
|
||||
return { status: 'in_progress' };
|
||||
}
|
||||
|
||||
// Plan review stage (human approval of spec before coding starts)
|
||||
if (isPlanReviewStage && storedStatus === 'human_review') {
|
||||
debugLog('[determineTaskStatusAndReason] Plan review stage detected:', {
|
||||
planStatus: plan.status,
|
||||
reason: 'Spec creation complete, awaiting user approval'
|
||||
});
|
||||
return { status: 'human_review', reviewReason: 'plan_review' };
|
||||
}
|
||||
|
||||
// Explicit human_review status should be preserved unless we have evidence to change it
|
||||
if (storedStatus === 'human_review') {
|
||||
// Infer review reason from subtask/QA state
|
||||
const hasFailedSubtasks = allSubtasks.some((s) => s.status === 'failed');
|
||||
const allCompleted = allSubtasks.length > 0 && allSubtasks.every((s) => s.status === 'completed');
|
||||
let reviewReason: ReviewReason | undefined;
|
||||
if (hasFailedSubtasks) {
|
||||
reviewReason = 'errors';
|
||||
} else if (allCompleted) {
|
||||
reviewReason = 'completed';
|
||||
}
|
||||
debugLog('[determineTaskStatusAndReason] Explicit human_review preserved:', {
|
||||
planStatus: plan.status,
|
||||
reviewReason,
|
||||
hasFailedSubtasks,
|
||||
allCompleted,
|
||||
subtaskCount: allSubtasks.length
|
||||
});
|
||||
return { status: 'human_review', reviewReason };
|
||||
}
|
||||
|
||||
// Explicit ai_review status should be preserved
|
||||
if (storedStatus === 'ai_review') {
|
||||
debugLog('[determineTaskStatusAndReason] Explicit ai_review preserved:', {
|
||||
planStatus: plan.status,
|
||||
subtaskCount: allSubtasks.length
|
||||
});
|
||||
return { status: 'ai_review' };
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// STEP 3: Check QA report file for status info
|
||||
// ========================================================================
|
||||
const qaReportPath = path.join(specPath, AUTO_BUILD_PATHS.QA_REPORT);
|
||||
if (existsSync(qaReportPath)) {
|
||||
try {
|
||||
const content = readFileSync(qaReportPath, 'utf-8');
|
||||
if (content.includes('REJECTED') || content.includes('FAILED')) {
|
||||
debugLog('[determineTaskStatusAndReason] QA report indicates rejection:', {
|
||||
qaReportPath,
|
||||
reason: 'QA rejected - needs human attention'
|
||||
});
|
||||
return { status: 'human_review', reviewReason: 'qa_rejected' };
|
||||
}
|
||||
if (content.includes('PASSED') || content.includes('APPROVED')) {
|
||||
// QA passed - if all subtasks done, move to human_review
|
||||
if (allSubtasks.length > 0 && allSubtasks.every((s) => s.status === 'completed')) {
|
||||
debugLog('[determineTaskStatusAndReason] QA passed with all subtasks complete:', {
|
||||
qaReportPath,
|
||||
subtaskCount: allSubtasks.length
|
||||
});
|
||||
return { status: 'human_review', reviewReason: 'completed' };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// STEP 4: Calculate status from subtask analysis (fallback only)
|
||||
// This is the lowest priority - only used when no explicit status is set
|
||||
// ========================================================================
|
||||
let calculatedStatus: TaskStatus = 'backlog';
|
||||
let reviewReason: ReviewReason | undefined;
|
||||
|
||||
@@ -582,148 +718,24 @@ export class ProjectStore {
|
||||
}
|
||||
}
|
||||
|
||||
// FIRST: Check for explicit user-set status from plan (takes highest priority)
|
||||
// This allows users to manually mark tasks as 'done' via drag-and-drop
|
||||
if (plan?.status) {
|
||||
const statusMap: Record<string, TaskStatus> = {
|
||||
'pending': 'backlog',
|
||||
'planning': 'in_progress', // Task is in planning phase (spec creation running)
|
||||
'in_progress': 'in_progress',
|
||||
'coding': 'in_progress', // Task is in coding phase
|
||||
'review': 'ai_review',
|
||||
'completed': 'done',
|
||||
'done': 'done',
|
||||
'human_review': 'human_review',
|
||||
'ai_review': 'ai_review',
|
||||
'pr_created': 'pr_created', // PR has been created for this task
|
||||
'backlog': 'backlog'
|
||||
};
|
||||
const storedStatus = statusMap[plan.status];
|
||||
|
||||
// If user explicitly marked as 'done', always respect that
|
||||
if (storedStatus === 'done') {
|
||||
return { status: 'done' };
|
||||
}
|
||||
|
||||
// If task has a PR created, always respect that status
|
||||
if (storedStatus === 'pr_created') {
|
||||
return { status: 'pr_created' };
|
||||
}
|
||||
|
||||
// For other stored statuses, validate against calculated status
|
||||
if (storedStatus) {
|
||||
// Planning/coding status from the backend should be respected even if subtasks aren't in progress yet
|
||||
// This happens when a task is in planning phase (creating spec) but no subtasks have been started
|
||||
const isActiveProcessStatus = (plan.status as string) === 'planning' || (plan.status as string) === 'coding' || (plan.status as string) === 'in_progress';
|
||||
|
||||
// Check if this is a plan review (spec approval stage before coding starts)
|
||||
// planStatus: "review" indicates spec creation is complete and awaiting user approval
|
||||
const isPlanReviewStage = (plan as unknown as { planStatus?: string })?.planStatus === 'review';
|
||||
|
||||
// Determine if there is remaining work to do
|
||||
// True if: no subtasks exist yet (planning in progress) OR some subtasks are incomplete
|
||||
// This prevents 'in_progress' from overriding 'human_review' when all work is done
|
||||
const hasRemainingWork = allSubtasks.length === 0 || allSubtasks.some((s) => s.status !== 'completed');
|
||||
|
||||
const isStoredStatusValid =
|
||||
(storedStatus === calculatedStatus) || // Matches calculated
|
||||
(storedStatus === 'human_review' && (calculatedStatus === 'ai_review' || calculatedStatus === 'in_progress')) || // Human review is more advanced than ai_review or in_progress (fixes status loop bug)
|
||||
(storedStatus === 'human_review' && isPlanReviewStage) || // Plan review stage (awaiting spec approval)
|
||||
(isActiveProcessStatus && storedStatus === 'in_progress' && hasRemainingWork); // Planning/coding phases should show as in_progress ONLY when there's remaining work
|
||||
|
||||
if (isStoredStatusValid) {
|
||||
// Preserve reviewReason for human_review status
|
||||
if (storedStatus === 'human_review' && !reviewReason) {
|
||||
// Infer reason from subtask states or plan review stage
|
||||
const hasFailedSubtasks = allSubtasks.some((s) => s.status === 'failed');
|
||||
const allCompleted = allSubtasks.length > 0 && allSubtasks.every((s) => s.status === 'completed');
|
||||
if (hasFailedSubtasks) {
|
||||
reviewReason = 'errors';
|
||||
} else if (allCompleted) {
|
||||
reviewReason = 'completed';
|
||||
} else if (isPlanReviewStage) {
|
||||
reviewReason = 'plan_review';
|
||||
}
|
||||
}
|
||||
return { status: storedStatus, reviewReason: storedStatus === 'human_review' ? reviewReason : undefined };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SECOND: Check QA report file for additional status info
|
||||
const qaReportPath = path.join(specPath, AUTO_BUILD_PATHS.QA_REPORT);
|
||||
if (existsSync(qaReportPath)) {
|
||||
try {
|
||||
const content = readFileSync(qaReportPath, 'utf-8');
|
||||
if (content.includes('REJECTED') || content.includes('FAILED')) {
|
||||
return { status: 'human_review', reviewReason: 'qa_rejected' };
|
||||
}
|
||||
if (content.includes('PASSED') || content.includes('APPROVED')) {
|
||||
// QA passed - if all subtasks done, move to human_review
|
||||
if (allSubtasks.length > 0 && allSubtasks.every((s) => s.status === 'completed')) {
|
||||
return { status: 'human_review', reviewReason: 'completed' };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
// Log calculated status (fallback path - no explicit status was set)
|
||||
debugLog('[determineTaskStatusAndReason] Status calculated from subtasks (fallback):', {
|
||||
planStatus: plan?.status || 'none',
|
||||
calculatedStatus,
|
||||
reviewReason,
|
||||
subtaskStats: {
|
||||
total: allSubtasks.length,
|
||||
completed: allSubtasks.filter((s) => s.status === 'completed').length,
|
||||
inProgress: allSubtasks.filter((s) => s.status === 'in_progress').length,
|
||||
failed: allSubtasks.filter((s) => s.status === 'failed').length,
|
||||
pending: allSubtasks.filter((s) => s.status === 'pending').length
|
||||
},
|
||||
isManual: metadata?.sourceType === 'manual'
|
||||
});
|
||||
|
||||
return { status: calculatedStatus, reviewReason: calculatedStatus === 'human_review' ? reviewReason : undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate taskId to prevent path traversal attacks
|
||||
* Returns true if taskId is safe to use in path operations
|
||||
*/
|
||||
private isValidTaskId(taskId: string): boolean {
|
||||
// Reject empty, null/undefined, or strings with path traversal characters
|
||||
if (!taskId || typeof taskId !== 'string') return false;
|
||||
if (taskId.includes('/') || taskId.includes('\\')) return false;
|
||||
if (taskId === '.' || taskId === '..') return false;
|
||||
if (taskId.includes('\0')) return false; // Null byte injection
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL spec paths for a task, checking main directory and worktrees
|
||||
* A task can exist in multiple locations (main + worktree), so return all paths
|
||||
*/
|
||||
private findAllSpecPaths(projectPath: string, specsBaseDir: string, taskId: string): string[] {
|
||||
// Validate taskId to prevent path traversal
|
||||
if (!this.isValidTaskId(taskId)) {
|
||||
console.error(`[ProjectStore] findAllSpecPaths: Invalid taskId rejected: ${taskId}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const paths: string[] = [];
|
||||
|
||||
// 1. Check main specs directory
|
||||
const mainSpecPath = path.join(projectPath, specsBaseDir, taskId);
|
||||
if (existsSync(mainSpecPath)) {
|
||||
paths.push(mainSpecPath);
|
||||
}
|
||||
|
||||
// 2. Check worktrees
|
||||
const worktreesDir = getTaskWorktreeDir(projectPath);
|
||||
if (existsSync(worktreesDir)) {
|
||||
try {
|
||||
const worktrees = readdirSync(worktreesDir, { withFileTypes: true });
|
||||
for (const worktree of worktrees) {
|
||||
if (!worktree.isDirectory()) continue;
|
||||
const worktreeSpecPath = path.join(worktreesDir, worktree.name, specsBaseDir, taskId);
|
||||
if (existsSync(worktreeSpecPath)) {
|
||||
paths.push(worktreeSpecPath);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors reading worktrees
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive tasks by writing archivedAt to their metadata
|
||||
* @param projectId - Project ID
|
||||
@@ -743,7 +755,7 @@ export class ProjectStore {
|
||||
|
||||
for (const taskId of taskIds) {
|
||||
// Find ALL locations where this task exists (main + worktrees)
|
||||
const specPaths = this.findAllSpecPaths(project.path, specsBaseDir, taskId);
|
||||
const specPaths = findAllSpecPaths(project.path, specsBaseDir, taskId);
|
||||
|
||||
// If spec directory doesn't exist anywhere, skip gracefully
|
||||
if (specPaths.length === 0) {
|
||||
@@ -806,7 +818,7 @@ export class ProjectStore {
|
||||
|
||||
for (const taskId of taskIds) {
|
||||
// Find ALL locations where this task exists (main + worktrees)
|
||||
const specPaths = this.findAllSpecPaths(project.path, specsBaseDir, taskId);
|
||||
const specPaths = findAllSpecPaths(project.path, specsBaseDir, taskId);
|
||||
|
||||
if (specPaths.length === 0) {
|
||||
console.warn(`[ProjectStore] unarchiveTasks: Spec directory not found for task ${taskId}`);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Shared utilities for spec path operations
|
||||
*
|
||||
* These functions are used by both project-store.ts and crud-handlers.ts
|
||||
* to ensure consistent validation and path resolution.
|
||||
*/
|
||||
import path from 'path';
|
||||
import { existsSync, readdirSync } from 'fs';
|
||||
import { getTaskWorktreeDir } from '../worktree-paths';
|
||||
|
||||
/**
|
||||
* Validate taskId to prevent path traversal attacks
|
||||
* Returns true if taskId is safe to use in path operations
|
||||
*
|
||||
* @param taskId - The task ID to validate
|
||||
* @returns true if the taskId is safe to use in path operations
|
||||
*/
|
||||
export function isValidTaskId(taskId: string): boolean {
|
||||
// Reject empty, null/undefined, or strings with path traversal characters
|
||||
if (!taskId || typeof taskId !== 'string') return false;
|
||||
if (taskId.includes('/') || taskId.includes('\\')) return false;
|
||||
if (taskId === '.' || taskId === '..') return false;
|
||||
if (taskId.includes('\0')) return false; // Null byte injection
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL spec paths for a task, checking main directory and worktrees
|
||||
* A task can exist in multiple locations (main + worktree), so return all paths
|
||||
*
|
||||
* @param projectPath - The root path of the project
|
||||
* @param specsBaseDir - The relative path to specs directory (e.g., '.auto-claude/specs')
|
||||
* @param taskId - The task/spec ID to find
|
||||
* @param logPrefix - Optional prefix for log messages (defaults to '[SpecPathHelpers]')
|
||||
* @returns Array of absolute paths where the spec exists
|
||||
*/
|
||||
export function findAllSpecPaths(
|
||||
projectPath: string,
|
||||
specsBaseDir: string,
|
||||
taskId: string,
|
||||
logPrefix: string = '[SpecPathHelpers]'
|
||||
): string[] {
|
||||
// Validate taskId to prevent path traversal
|
||||
if (!isValidTaskId(taskId)) {
|
||||
console.error(`${logPrefix} findAllSpecPaths: Invalid taskId rejected: ${taskId}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const paths: string[] = [];
|
||||
|
||||
// 1. Check main specs directory
|
||||
const mainSpecPath = path.join(projectPath, specsBaseDir, taskId);
|
||||
if (existsSync(mainSpecPath)) {
|
||||
paths.push(mainSpecPath);
|
||||
}
|
||||
|
||||
// 2. Check worktrees
|
||||
const worktreesDir = getTaskWorktreeDir(projectPath);
|
||||
if (existsSync(worktreesDir)) {
|
||||
try {
|
||||
const worktrees = readdirSync(worktreesDir, { withFileTypes: true });
|
||||
for (const worktree of worktrees) {
|
||||
if (!worktree.isDirectory()) continue;
|
||||
const worktreeSpecPath = path.join(worktreesDir, worktree.name, specsBaseDir, taskId);
|
||||
if (existsSync(worktreeSpecPath)) {
|
||||
paths.push(worktreeSpecPath);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors reading worktrees
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
|
||||
export interface TaskAPI {
|
||||
// Task Operations
|
||||
getTasks: (projectId: string) => Promise<IPCResult<Task[]>>;
|
||||
getTasks: (projectId: string, options?: { forceRefresh?: boolean }) => Promise<IPCResult<Task[]>>;
|
||||
createTask: (
|
||||
projectId: string,
|
||||
title: string,
|
||||
@@ -85,8 +85,8 @@ export interface TaskAPI {
|
||||
|
||||
export const createTaskAPI = (): TaskAPI => ({
|
||||
// Task Operations
|
||||
getTasks: (projectId: string): Promise<IPCResult<Task[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_LIST, projectId),
|
||||
getTasks: (projectId: string, options?: { forceRefresh?: boolean }): Promise<IPCResult<Task[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_LIST, projectId, options),
|
||||
|
||||
createTask: (
|
||||
projectId: string,
|
||||
|
||||
@@ -542,7 +542,9 @@ export function App() {
|
||||
if (!currentProjectId) return;
|
||||
setIsRefreshingTasks(true);
|
||||
try {
|
||||
await loadTasks(currentProjectId);
|
||||
// Pass forceRefresh: true to invalidate cache and get fresh data from disk
|
||||
// This ensures the refresh button always shows the latest task state
|
||||
await loadTasks(currentProjectId, { forceRefresh: true });
|
||||
} finally {
|
||||
setIsRefreshingTasks(false);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ function createTestTaskOrder(overrides: Partial<TaskOrderState> = {}): TaskOrder
|
||||
human_review: [],
|
||||
pr_created: [],
|
||||
done: [],
|
||||
error: [],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
@@ -249,7 +250,8 @@ describe('Task Order State Management', () => {
|
||||
ai_review: [],
|
||||
human_review: [],
|
||||
pr_created: [],
|
||||
done: []
|
||||
done: [],
|
||||
error: []
|
||||
});
|
||||
});
|
||||
|
||||
@@ -281,7 +283,8 @@ describe('Task Order State Management', () => {
|
||||
ai_review: [],
|
||||
human_review: [],
|
||||
pr_created: [],
|
||||
done: []
|
||||
done: [],
|
||||
error: []
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to load task order:', expect.any(Error));
|
||||
|
||||
@@ -307,7 +310,8 @@ describe('Task Order State Management', () => {
|
||||
ai_review: [],
|
||||
human_review: [],
|
||||
pr_created: [],
|
||||
done: []
|
||||
done: [],
|
||||
error: []
|
||||
});
|
||||
|
||||
localStorage.getItem = originalGetItem;
|
||||
@@ -495,7 +499,8 @@ describe('Task Order State Management', () => {
|
||||
ai_review: [],
|
||||
human_review: [],
|
||||
pr_created: [],
|
||||
done: []
|
||||
done: [],
|
||||
error: []
|
||||
} as TaskOrderState;
|
||||
useTaskStore.setState({ taskOrder: order });
|
||||
|
||||
@@ -592,7 +597,8 @@ describe('Task Order State Management', () => {
|
||||
ai_review: [],
|
||||
human_review: [],
|
||||
pr_created: [],
|
||||
done: []
|
||||
done: [],
|
||||
error: []
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
|
||||
@@ -1346,5 +1346,589 @@ describe('Task Store', () => {
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('human_review');
|
||||
});
|
||||
});
|
||||
|
||||
// FIX (Subtask 4-2): Comprehensive tests for all active execution phases
|
||||
describe('active execution phase protection - all phases', () => {
|
||||
it('should NOT update status when task is in qa_review phase', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
status: 'in_progress',
|
||||
executionProgress: { phase: 'qa_review', phaseProgress: 50, overallProgress: 80 }
|
||||
})]
|
||||
});
|
||||
|
||||
const plan = createTestPlan({
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{ id: 'c1', description: 'Subtask 1', status: 'completed' },
|
||||
{ id: 'c2', description: 'Subtask 2', status: 'completed' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Status should remain in_progress during qa_review phase
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('in_progress');
|
||||
});
|
||||
|
||||
it('should NOT update status when task is in qa_fixing phase', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
status: 'in_progress',
|
||||
executionProgress: { phase: 'qa_fixing', phaseProgress: 30, overallProgress: 70 }
|
||||
})]
|
||||
});
|
||||
|
||||
const plan = createTestPlan({
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{ id: 'c1', description: 'Subtask 1', status: 'completed' },
|
||||
{ id: 'c2', description: 'Subtask 2', status: 'completed' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Status should remain in_progress during qa_fixing phase
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('in_progress');
|
||||
});
|
||||
|
||||
it('should still update subtasks when status recalculation is blocked', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
status: 'in_progress',
|
||||
subtasks: [],
|
||||
executionProgress: { phase: 'coding', phaseProgress: 50, overallProgress: 40 }
|
||||
})]
|
||||
});
|
||||
|
||||
const plan = createTestPlan({
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{ id: 'c1', description: 'Subtask 1', status: 'completed' },
|
||||
{ id: 'c2', description: 'Subtask 2', status: 'in_progress' },
|
||||
{ id: 'c3', description: 'Subtask 3', status: 'pending' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Status should stay in_progress (blocked by active phase)
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('in_progress');
|
||||
// But subtasks should still be updated
|
||||
expect(useTaskStore.getState().tasks[0].subtasks).toHaveLength(3);
|
||||
expect(useTaskStore.getState().tasks[0].subtasks[0].status).toBe('completed');
|
||||
expect(useTaskStore.getState().tasks[0].subtasks[1].status).toBe('in_progress');
|
||||
expect(useTaskStore.getState().tasks[0].subtasks[2].status).toBe('pending');
|
||||
});
|
||||
|
||||
it('should update title even when status recalculation is blocked', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
title: 'Original Title',
|
||||
status: 'in_progress',
|
||||
executionProgress: { phase: 'planning', phaseProgress: 50, overallProgress: 10 }
|
||||
})]
|
||||
});
|
||||
|
||||
const plan = createTestPlan({
|
||||
feature: 'New Feature Name',
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{ id: 'c1', description: 'Subtask 1', status: 'completed' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Status should stay in_progress (blocked by active phase)
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('in_progress');
|
||||
// But title should still be updated
|
||||
expect(useTaskStore.getState().tasks[0].title).toBe('New Feature Name');
|
||||
});
|
||||
});
|
||||
|
||||
// FIX (Subtask 4-2): Tests for shouldBlockTerminalTransition logic
|
||||
describe('terminal transition blocking (shouldBlockTerminalTransition)', () => {
|
||||
it('should block ai_review when subtasks array is empty', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
status: 'backlog',
|
||||
subtasks: [],
|
||||
executionProgress: undefined
|
||||
})]
|
||||
});
|
||||
|
||||
// Plan with empty subtasks should not trigger ai_review
|
||||
const plan = createTestPlan({
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Status should remain backlog, not go to ai_review (no subtasks to complete)
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('backlog');
|
||||
});
|
||||
|
||||
it('should allow transition to ai_review when all subtasks are completed', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
status: 'backlog',
|
||||
subtasks: [],
|
||||
executionProgress: undefined
|
||||
})]
|
||||
});
|
||||
|
||||
const plan = createTestPlan({
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{ id: 'c1', description: 'Subtask 1', status: 'completed' },
|
||||
{ id: 'c2', description: 'Subtask 2', status: 'completed' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Status should transition to ai_review when all subtasks are completed
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('ai_review');
|
||||
});
|
||||
|
||||
it('should allow transition to human_review when any subtask failed', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
status: 'in_progress',
|
||||
subtasks: [],
|
||||
executionProgress: undefined
|
||||
})]
|
||||
});
|
||||
|
||||
const plan = createTestPlan({
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{ id: 'c1', description: 'Subtask 1', status: 'completed' },
|
||||
{ id: 'c2', description: 'Subtask 2', status: 'failed' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Status should transition to human_review when any subtask failed
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('human_review');
|
||||
});
|
||||
|
||||
it('should transition to in_progress when some subtasks are in progress', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
status: 'backlog',
|
||||
subtasks: [],
|
||||
executionProgress: undefined
|
||||
})]
|
||||
});
|
||||
|
||||
const plan = createTestPlan({
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{ id: 'c1', description: 'Subtask 1', status: 'completed' },
|
||||
{ id: 'c2', description: 'Subtask 2', status: 'in_progress' },
|
||||
{ id: 'c3', description: 'Subtask 3', status: 'pending' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Status should transition to in_progress when some subtasks are in progress
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('in_progress');
|
||||
});
|
||||
|
||||
it('should transition to in_progress when only some subtasks are completed', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
status: 'backlog',
|
||||
subtasks: [],
|
||||
executionProgress: undefined
|
||||
})]
|
||||
});
|
||||
|
||||
const plan = createTestPlan({
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{ id: 'c1', description: 'Subtask 1', status: 'completed' },
|
||||
{ id: 'c2', description: 'Subtask 2', status: 'pending' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Status should transition to in_progress (some completed but not all)
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('in_progress');
|
||||
});
|
||||
});
|
||||
|
||||
// FIX (Subtask 4-2): Combined guard tests
|
||||
describe('combined status stability guards', () => {
|
||||
it('should protect status when in terminal phase AND terminal status', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
status: 'done',
|
||||
executionProgress: { phase: 'complete', phaseProgress: 100, overallProgress: 100 }
|
||||
})]
|
||||
});
|
||||
|
||||
const plan = createTestPlan({
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{ id: 'c1', description: 'Subtask 1', status: 'pending' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Status should remain done (protected by both terminal phase and terminal status)
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('done');
|
||||
});
|
||||
|
||||
it('should protect status when pr_created even without terminal phase', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
status: 'pr_created',
|
||||
executionProgress: { phase: 'idle', phaseProgress: 0, overallProgress: 0 }
|
||||
})]
|
||||
});
|
||||
|
||||
const plan = createTestPlan({
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{ id: 'c1', description: 'Subtask 1', status: 'completed' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Status should remain pr_created (protected by terminal status)
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('pr_created');
|
||||
});
|
||||
|
||||
it('should protect status in failed phase even with all subtasks completed', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
status: 'human_review',
|
||||
executionProgress: { phase: 'failed', phaseProgress: 50, overallProgress: 30 }
|
||||
})]
|
||||
});
|
||||
|
||||
const plan = createTestPlan({
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{ id: 'c1', description: 'Subtask 1', status: 'completed' },
|
||||
{ id: 'c2', description: 'Subtask 2', status: 'completed' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Status should remain human_review (protected by terminal phase 'failed')
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('human_review');
|
||||
});
|
||||
|
||||
it('should NOT protect non-terminal status in non-active phase', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
status: 'in_progress',
|
||||
executionProgress: { phase: 'idle', phaseProgress: 0, overallProgress: 0 }
|
||||
})]
|
||||
});
|
||||
|
||||
const plan = createTestPlan({
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{ id: 'c1', description: 'Subtask 1', status: 'completed' },
|
||||
{ id: 'c2', description: 'Subtask 2', status: 'completed' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Status should change to ai_review (not protected)
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('ai_review');
|
||||
});
|
||||
|
||||
it('should NOT update status from backlog to ai_review during active planning', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
status: 'backlog',
|
||||
executionProgress: { phase: 'planning', phaseProgress: 10, overallProgress: 5 }
|
||||
})]
|
||||
});
|
||||
|
||||
const plan = createTestPlan({
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{ id: 'c1', description: 'Subtask 1', status: 'completed' },
|
||||
{ id: 'c2', description: 'Subtask 2', status: 'completed' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Status should remain backlog (blocked by active planning phase)
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('backlog');
|
||||
});
|
||||
});
|
||||
|
||||
// FIX (Subtask 4-2): Status stability edge cases
|
||||
describe('status stability edge cases', () => {
|
||||
it('should handle missing executionProgress gracefully', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
status: 'in_progress'
|
||||
} as Partial<Task>)]
|
||||
});
|
||||
|
||||
// Explicitly remove executionProgress
|
||||
const task = useTaskStore.getState().tasks[0];
|
||||
delete (task as any).executionProgress;
|
||||
|
||||
const plan = createTestPlan({
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{ id: 'c1', description: 'Subtask 1', status: 'completed' },
|
||||
{ id: 'c2', description: 'Subtask 2', status: 'completed' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Should still recalculate status (no executionProgress = not in active phase)
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('ai_review');
|
||||
});
|
||||
|
||||
it('should handle undefined phase in executionProgress', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
status: 'in_progress',
|
||||
executionProgress: { phaseProgress: 0, overallProgress: 0 } as any
|
||||
})]
|
||||
});
|
||||
|
||||
const plan = createTestPlan({
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{ id: 'c1', description: 'Subtask 1', status: 'completed' },
|
||||
{ id: 'c2', description: 'Subtask 2', status: 'completed' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Should recalculate status (undefined phase = not in active phase)
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('ai_review');
|
||||
});
|
||||
|
||||
it('should preserve reviewReason when status changes to human_review', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
status: 'in_progress',
|
||||
reviewReason: undefined
|
||||
})]
|
||||
});
|
||||
|
||||
const plan = createTestPlan({
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{ id: 'c1', description: 'Subtask 1', status: 'completed' },
|
||||
{ id: 'c2', description: 'Subtask 2', status: 'failed' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Status should change to human_review with errors reason
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('human_review');
|
||||
expect(useTaskStore.getState().tasks[0].reviewReason).toBe('errors');
|
||||
});
|
||||
|
||||
it('should update reviewReason when task is already in human_review and plan has failures', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
status: 'human_review',
|
||||
reviewReason: 'qa_rejected'
|
||||
})]
|
||||
});
|
||||
|
||||
const plan = createTestPlan({
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{ id: 'c1', description: 'Subtask 1', status: 'completed' },
|
||||
{ id: 'c2', description: 'Subtask 2', status: 'failed' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Status should remain human_review (terminal status in terminalStatuses)
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('human_review');
|
||||
// reviewReason should be updated to reflect the current failure state from the plan
|
||||
// This is intentional - the plan's failure state takes precedence to show current state
|
||||
expect(useTaskStore.getState().tasks[0].reviewReason).toBe('errors');
|
||||
});
|
||||
|
||||
it('should preserve reviewReason when task is in terminal status with no failures', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
status: 'human_review',
|
||||
reviewReason: 'completed'
|
||||
})]
|
||||
});
|
||||
|
||||
const plan = createTestPlan({
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{ id: 'c1', description: 'Subtask 1', status: 'completed' },
|
||||
{ id: 'c2', description: 'Subtask 2', status: 'completed' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Status should remain human_review (protected by terminalStatuses check)
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('human_review');
|
||||
// reviewReason should be preserved since allCompleted branch is also blocked
|
||||
expect(useTaskStore.getState().tasks[0].reviewReason).toBe('completed');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,10 +43,13 @@ function isValidDropColumn(id: string): id is typeof TASK_STATUS_COLUMNS[number]
|
||||
/**
|
||||
* Get the visual column for a task status.
|
||||
* pr_created tasks are displayed in the 'done' column, so we map them accordingly.
|
||||
* error tasks are displayed in the 'human_review' column (errors need human attention).
|
||||
* This is used to compare visual positions during drag-and-drop operations.
|
||||
*/
|
||||
function getVisualColumn(status: TaskStatus): typeof TASK_STATUS_COLUMNS[number] {
|
||||
return status === 'pr_created' ? 'done' : status;
|
||||
if (status === 'pr_created') return 'done';
|
||||
if (status === 'error') return 'human_review';
|
||||
return status;
|
||||
}
|
||||
|
||||
interface KanbanBoardProps {
|
||||
@@ -478,6 +481,7 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
|
||||
const tasksByStatus = useMemo(() => {
|
||||
// Note: pr_created tasks are shown in the 'done' column since they're essentially complete
|
||||
// Note: error tasks are shown in the 'human_review' column since they need human attention
|
||||
const grouped: Record<typeof TASK_STATUS_COLUMNS[number], Task[]> = {
|
||||
backlog: [],
|
||||
in_progress: [],
|
||||
@@ -487,8 +491,8 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
};
|
||||
|
||||
filteredTasks.forEach((task) => {
|
||||
// Map pr_created tasks to the done column
|
||||
const targetColumn = task.status === 'pr_created' ? 'done' : task.status;
|
||||
// Map pr_created tasks to the done column, error tasks to human_review
|
||||
const targetColumn = getVisualColumn(task.status);
|
||||
if (grouped[targetColumn]) {
|
||||
grouped[targetColumn].push(task);
|
||||
}
|
||||
@@ -741,7 +745,8 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
ai_review: [],
|
||||
human_review: [],
|
||||
pr_created: [],
|
||||
done: []
|
||||
done: [],
|
||||
error: []
|
||||
};
|
||||
|
||||
for (const status of Object.keys(taskOrder) as Array<keyof typeof taskOrder>) {
|
||||
|
||||
@@ -124,7 +124,8 @@ function createEmptyTaskOrder(): TaskOrderState {
|
||||
ai_review: [],
|
||||
human_review: [],
|
||||
pr_created: [],
|
||||
done: []
|
||||
done: [],
|
||||
error: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,13 +181,20 @@ export const useTaskStore = create<TaskState>((set, get) => ({
|
||||
updateTaskStatus: (taskId, status) =>
|
||||
set((state) => {
|
||||
const index = findTaskIndex(state.tasks, taskId);
|
||||
if (index === -1) return state;
|
||||
if (index === -1) {
|
||||
debugLog('[updateTaskStatus] Task not found:', taskId);
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
tasks: updateTaskAtIndex(state.tasks, index, (t) => {
|
||||
// Determine execution progress based on status transition
|
||||
let executionProgress = t.executionProgress;
|
||||
|
||||
// Track status transition for debugging flip-flop issues
|
||||
const previousStatus = t.status;
|
||||
const statusChanged = previousStatus !== status;
|
||||
|
||||
if (status === 'backlog') {
|
||||
// When status goes to backlog, reset execution progress to idle
|
||||
// This ensures the planning/coding animation stops when task is stopped
|
||||
@@ -197,6 +205,16 @@ export const useTaskStore = create<TaskState>((set, get) => ({
|
||||
executionProgress = { phase: 'planning' as ExecutionPhase, phaseProgress: 0, overallProgress: 0 };
|
||||
}
|
||||
|
||||
// Log status transitions to help diagnose flip-flop issues
|
||||
debugLog('[updateTaskStatus] Status transition:', {
|
||||
taskId,
|
||||
previousStatus,
|
||||
newStatus: status,
|
||||
statusChanged,
|
||||
currentPhase: t.executionProgress?.phase,
|
||||
newPhase: executionProgress?.phase
|
||||
});
|
||||
|
||||
return { ...t, status, executionProgress, updatedAt: new Date() };
|
||||
})
|
||||
};
|
||||
@@ -272,12 +290,21 @@ export const useTaskStore = create<TaskState>((set, get) => ({
|
||||
let reviewReason: ReviewReason | undefined = t.reviewReason;
|
||||
|
||||
// RACE CONDITION FIX: Don't let stale plan data override status during active execution
|
||||
// Strengthen guard: ANY active phase means NO status recalculation from plan data
|
||||
const activePhases: ExecutionPhase[] = ['planning', 'coding', 'qa_review', 'qa_fixing'];
|
||||
const isInActivePhase = t.executionProgress?.phase && activePhases.includes(t.executionProgress.phase);
|
||||
const isInActivePhase = Boolean(t.executionProgress?.phase && activePhases.includes(t.executionProgress.phase));
|
||||
|
||||
// FIX (Flip-Flop Bug): Terminal phases should NOT trigger status recalculation
|
||||
// When phase is 'complete' or 'failed', the task has finished and status should be stable
|
||||
const isInTerminalPhase = t.executionProgress?.phase && isTerminalPhase(t.executionProgress.phase);
|
||||
const isInTerminalPhase = Boolean(t.executionProgress?.phase && isTerminalPhase(t.executionProgress.phase));
|
||||
|
||||
// FIX (Subtask 2-1): Terminal task statuses should NEVER be recalculated from plan data
|
||||
// pr_created, done, and error are finalized workflow states set by explicit user/system actions
|
||||
// Once a task reaches these statuses, they should only change via explicit user actions (like drag-drop)
|
||||
// This prevents stale plan file reads from incorrectly downgrading completed tasks
|
||||
// NOTE: Keep this in sync with TERMINAL_STATUSES in project-store.ts
|
||||
const TERMINAL_TASK_STATUSES: TaskStatus[] = ['pr_created', 'done', 'error'];
|
||||
const isInTerminalStatus = TERMINAL_TASK_STATUSES.includes(t.status);
|
||||
|
||||
// FIX (Flip-Flop Bug): Respect explicit human_review status from plan file
|
||||
// When the plan explicitly says 'human_review', don't override it with calculated status
|
||||
@@ -317,9 +344,10 @@ export const useTaskStore = create<TaskState>((set, get) => ({
|
||||
// Only recalculate status if:
|
||||
// 1. NOT in an active execution phase (planning, coding, qa_review, qa_fixing)
|
||||
// 2. NOT in a terminal phase (complete, failed) - status should be stable
|
||||
// 3. Plan doesn't explicitly say human_review
|
||||
// 4. Would not create an invalid terminal transition (ACS-203)
|
||||
if (!isInActivePhase && !isInTerminalPhase && !isExplicitHumanReview) {
|
||||
// 3. NOT in a terminal task status (pr_created, done) - finalized workflow states
|
||||
// 4. Plan doesn't explicitly say human_review
|
||||
// 5. Would not create an invalid terminal transition (ACS-203)
|
||||
if (!isInActivePhase && !isInTerminalPhase && !isInTerminalStatus && !isExplicitHumanReview) {
|
||||
if (allCompleted && hasSubtasks) {
|
||||
// FIX (Flip-Flop Bug): Don't downgrade from terminal statuses to ai_review
|
||||
// Once a task reaches human_review, pr_created, or done, it should stay there
|
||||
@@ -360,6 +388,7 @@ export const useTaskStore = create<TaskState>((set, get) => ({
|
||||
newStatus: status,
|
||||
isInActivePhase,
|
||||
isInTerminalPhase,
|
||||
isInTerminalStatus,
|
||||
isExplicitHumanReview,
|
||||
planStatus,
|
||||
currentPhase: t.executionProgress?.phase,
|
||||
@@ -540,7 +569,8 @@ export const useTaskStore = create<TaskState>((set, get) => ({
|
||||
ai_review: isValidColumnArray(parsed.ai_review) ? parsed.ai_review : emptyOrder.ai_review,
|
||||
human_review: isValidColumnArray(parsed.human_review) ? parsed.human_review : emptyOrder.human_review,
|
||||
pr_created: isValidColumnArray(parsed.pr_created) ? parsed.pr_created : emptyOrder.pr_created,
|
||||
done: isValidColumnArray(parsed.done) ? parsed.done : emptyOrder.done
|
||||
done: isValidColumnArray(parsed.done) ? parsed.done : emptyOrder.done,
|
||||
error: isValidColumnArray(parsed.error) ? parsed.error : emptyOrder.error
|
||||
};
|
||||
|
||||
set({ taskOrder: validatedOrder });
|
||||
@@ -593,14 +623,17 @@ export const useTaskStore = create<TaskState>((set, get) => ({
|
||||
|
||||
/**
|
||||
* Load tasks for a project
|
||||
* @param projectId - The project ID to load tasks for
|
||||
* @param options - Optional parameters
|
||||
* @param options.forceRefresh - If true, invalidates server-side cache before fetching (for refresh button)
|
||||
*/
|
||||
export async function loadTasks(projectId: string): Promise<void> {
|
||||
export async function loadTasks(projectId: string, options?: { forceRefresh?: boolean }): Promise<void> {
|
||||
const store = useTaskStore.getState();
|
||||
store.setLoading(true);
|
||||
store.setError(null);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.getTasks(projectId);
|
||||
const result = await window.electronAPI.getTasks(projectId, options);
|
||||
if (result.success && result.data) {
|
||||
store.setTasks(result.data);
|
||||
} else {
|
||||
|
||||
@@ -20,24 +20,28 @@ export type TaskStatusColumn = typeof TASK_STATUS_COLUMNS[number];
|
||||
|
||||
// Status label translation keys (use with t() from react-i18next)
|
||||
// Note: pr_created maps to 'done' column in Kanban view (see KanbanBoard.tsx)
|
||||
export const TASK_STATUS_LABELS: Record<TaskStatusColumn | 'pr_created', string> = {
|
||||
// Note: error maps to 'human_review' column in Kanban view (errors need human attention)
|
||||
export const TASK_STATUS_LABELS: Record<TaskStatusColumn | 'pr_created' | 'error', string> = {
|
||||
backlog: 'columns.backlog',
|
||||
in_progress: 'columns.in_progress',
|
||||
ai_review: 'columns.ai_review',
|
||||
human_review: 'columns.human_review',
|
||||
done: 'columns.done',
|
||||
pr_created: 'columns.pr_created'
|
||||
pr_created: 'columns.pr_created',
|
||||
error: 'columns.error'
|
||||
};
|
||||
|
||||
// Status colors for UI
|
||||
// Note: pr_created maps to 'done' column in Kanban view (see KanbanBoard.tsx)
|
||||
export const TASK_STATUS_COLORS: Record<TaskStatusColumn | 'pr_created', string> = {
|
||||
// Note: error maps to 'human_review' column in Kanban view (errors need human attention)
|
||||
export const TASK_STATUS_COLORS: Record<TaskStatusColumn | 'pr_created' | 'error', string> = {
|
||||
backlog: 'bg-muted text-muted-foreground',
|
||||
in_progress: 'bg-info/10 text-info',
|
||||
ai_review: 'bg-warning/10 text-warning',
|
||||
human_review: 'bg-purple-500/10 text-purple-400',
|
||||
done: 'bg-success/10 text-success',
|
||||
pr_created: 'bg-info/10 text-info'
|
||||
pr_created: 'bg-info/10 text-info',
|
||||
error: 'bg-destructive/10 text-destructive'
|
||||
};
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -59,7 +59,8 @@
|
||||
"ai_review": "AI Review",
|
||||
"human_review": "Human Review",
|
||||
"done": "Done",
|
||||
"pr_created": "PR Created"
|
||||
"pr_created": "PR Created",
|
||||
"error": "Error"
|
||||
},
|
||||
"kanban": {
|
||||
"emptyBacklog": "No tasks planned",
|
||||
|
||||
@@ -59,7 +59,8 @@
|
||||
"ai_review": "Révision IA",
|
||||
"human_review": "Révision humaine",
|
||||
"done": "Terminé",
|
||||
"pr_created": "PR créée"
|
||||
"pr_created": "PR créée",
|
||||
"error": "Erreur"
|
||||
},
|
||||
"kanban": {
|
||||
"emptyBacklog": "Aucune tâche planifiée",
|
||||
|
||||
@@ -156,7 +156,7 @@ export interface ElectronAPI {
|
||||
saveTabState: (tabState: TabState) => Promise<IPCResult>;
|
||||
|
||||
// Task operations
|
||||
getTasks: (projectId: string) => Promise<IPCResult<Task[]>>;
|
||||
getTasks: (projectId: string, options?: { forceRefresh?: boolean }) => Promise<IPCResult<Task[]>>;
|
||||
createTask: (projectId: string, title: string, description: string, metadata?: TaskMetadata) => Promise<IPCResult<Task>>;
|
||||
deleteTask: (taskId: string) => Promise<IPCResult>;
|
||||
updateTask: (taskId: string, updates: { title?: string; description?: string }) => Promise<IPCResult<Task>>;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { ThinkingLevel, PhaseModelConfig, PhaseThinkingConfig } from './settings';
|
||||
import type { ExecutionPhase as ExecutionPhaseType, CompletablePhase } from '../constants/phase-protocol';
|
||||
|
||||
export type TaskStatus = 'backlog' | 'in_progress' | 'ai_review' | 'human_review' | 'pr_created' | 'done';
|
||||
export type TaskStatus = 'backlog' | 'in_progress' | 'ai_review' | 'human_review' | 'pr_created' | 'done' | 'error';
|
||||
|
||||
// Maps task status columns to ordered task IDs for kanban board reordering
|
||||
export type TaskOrderState = Record<TaskStatus, string[]>;
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../../../node_modules
|
||||
Reference in New Issue
Block a user