fix(core): implement atomic JSON writes to prevent file corruption (ACS-209) (#915)

* fix(core): implement atomic JSON writes to prevent file corruption (ACS-209)

- Add write_json_atomic() utility using temp file + os.replace()
- Add async_save() to ImplementationPlan for non-blocking I/O
- Update planner.py to use async_save() in async context
- Add 'error' status to TaskStatus for corrupted files
- Enhance ProjectStore error handling to surface parse errors
- Add recovery CLI utility (cli/recovery.py) for detecting/fixing corrupted files

This fixes JSON parse errors that caused tasks to be silently skipped
when implementation_plan.json was corrupted during crashes/interrupts.

* fix(i18n): add error column to task status constants and translations

- Add 'error' to TASK_STATUS_COLUMNS array
- Add 'error' label and color to TASK_STATUS_LABELS and TASK_STATUS_COLORS
- Add 'columns.error' translation key to en/tasks.json and fr/tasks.json

This fixes TypeScript errors in KanbanBoard.tsx where the Record type
was missing the 'error' status that was added to TaskStatus type.

* refactor: improve code quality and add i18n support for error messages

Backend improvements:
- Fix duplicate JSON check in recovery.py (remove redundant plan_file check)
- Update find_specs_dir return type to Path (always returns valid path)
- Replace deprecated asyncio.get_event_loop() with asyncio.get_running_loop()
- Use functools.partial for cleaner async_save implementation

Frontend improvements:
- Add TaskErrorInfo type for structured error information
- Update project-store.ts to use errorInfo for i18n-compatible error messages
- Tighten typing of TASK_STATUS_LABELS and TASK_STATUS_COLORS to use TaskStatusColumn
- Add error column to KanbanBoard grouped tasks initialization
- Add en/fr errors.json translation files for parse error messages

* docs: add errors.json to i18n translation namespaces

- Document new errors.json namespace for error messages
- Add example showing interpolation/substitution pattern for dynamic error content

* refactor: typing improvements, i18n fixes, and error UX enhancements

Backend typing:
- Add explicit return type hint (-> None) to main() in recovery.py
- Add explicit return type hint (-> None) to async_save() in plan.py
- Make recovery.py exit with code 1 when corrupted files are detected

Error handling improvements:
- Include specId in errorInfo meta for better error context
- Cap error message length at 500 characters to prevent bloat
- Update error translation keys to include specId substitution

Frontend improvements:
- Conditionally add reviewReason/errorInfo to task objects only when defined
- Add 'error' case to KanbanBoard empty state with AlertCircle icon
- Fix hardcoded "Refreshing..."/"Refresh Tasks" strings to use i18n

i18n additions:
- Add emptyError/emptyErrorHint to en/fr tasks.json
- Add refreshing/refreshTasks to en/fr translation files

* refactor: code quality improvements

- Remove error truncation in recovery.py (show full error for debugging)
- Extract duplicate timestamp/status update logic to _update_timestamps_and_status() helper
- Fix MD031 in CLAUDE.md (add blank line before fenced code block)

* refactor: code quality improvements

- Remove error truncation in recovery.py (show full error for debugging)
- Extract duplicate timestamp/status update logic to _update_timestamps_and_status() helper
- Fix MD031 in CLAUDE.md (add blank line before fenced code block)

* refactor: naming and typing improvements

- Update docstring examples in recovery.py (--fix -> --delete)
- Rename delete_corrupted_file to backup_corrupted_file for clarity
- Add return type annotation -> None to save() method

* fix: add error status handling to TaskCard

- Add 'error' case to getStatusBadgeVariant() returning 'destructive'
- Add 'error' case to getStatusLabel() returning t('columns.error')
- Start/stop buttons already exclude error tasks (backlog/in_progress only)

* fix: address PR review feedback

Backend changes:
- recovery.py: Change [DELETE] to [BACKUP] in output message to match operation
- recovery.py: Replace startswith with is_relative_to for proper path validation
- recovery.py: Handle existing .corrupted backup files with unique timestamp suffix
- file_utils.py: Add Iterator[IO[str]] return type annotation to atomic_write

Frontend changes:
- KanbanBoard.tsx: Use column-error CSS class instead of inline border-t-destructive
- globals.css: Add .column-error rule with destructive color for consistency
- tasks.json: Add top-level refreshTasks translation key for en/fr locales

* fix: address follow-up review findings

Backend changes:
- file_utils.py: Handle binary mode correctly (encoding=None for 'b' in mode)
- file_utils.py: Change return type to Iterator[IO] for broader type support
- file_utils.py: Add docstring note about binary mode support
- plan.py: Fix async_save to restore state on write failure (captures timestamps)

Frontend changes:
- project-store.ts: Add 'error' to statusMap to preserve error status
- project-store.ts: Add early return for 'error' status like 'done'/'pr_created'
- task-store.ts: Allow recovery from error status to backlog/in_progress
- task-store.ts: Allow transitions to error without completion validation

* fix: address follow-up review findings

Backend changes:
- file_utils.py: Handle binary mode correctly (encoding=None for 'b' in mode)
- file_utils.py: Change return type to Iterator[IO] for broader type support
- file_utils.py: Add docstring note about binary mode support
- plan.py: Fix async_save to restore state on write failure (captures timestamps)

Frontend changes:
- project-store.ts: Add 'error' to statusMap to preserve error status
- project-store.ts: Add early return for 'error' status like 'done'/'pr_created'
- task-store.ts: Allow recovery from error status to backlog/in_progress
- task-store.ts: Allow transitions to error without completion validation

* fix: address third follow-up review findings

Backend changes:
- recovery.py: Change spec_dir.glob to spec_dir.rglob for recursive JSON scan
- file_utils.py: Fix fd leak when os.fdopen raises (close fd and unlink tmp_path)
- plan.py: Capture full state with to_dict() for robust rollback in async_save

* fix: address final review quality suggestions

Backend changes:
- plan.py: Add NOTE comment about rollback fields maintenance
- file_utils.py: Add logging.warning for temp file cleanup failures

* fix: address final review quality suggestions

Backend changes:
- plan.py: Add NOTE comment about rollback fields maintenance
- file_utils.py: Add logging.warning for temp file cleanup failures

* fix: address final review quality suggestions

Backend changes:
- plan.py: Add NOTE comment about rollback fields maintenance
- file_utils.py: Add logging.warning for temp file cleanup failures

* fix: include stack trace in temp file cleanup failure logging

Add exc_info=True to logging.warning call when temp file cleanup fails.
This captures the full stack trace for better postmortem debugging of
orphaned temp files.

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
StillKnotKnown
2026-01-12 14:07:30 +02:00
committed by GitHub
parent 179744e221
commit 3c56a1ba32
20 changed files with 530 additions and 27 deletions
+11
View File
@@ -370,6 +370,7 @@ The frontend uses `react-i18next` for internationalization. All labels, buttons,
- `settings.json` - Settings page content
- `dialogs.json` - Dialog boxes and modals
- `tasks.json` - Task/spec related content
- `errors.json` - Error messages (structured error information with substitution support)
- `onboarding.json` - Onboarding wizard content
- `welcome.json` - Welcome screen content
@@ -385,6 +386,16 @@ const { t } = useTranslation(['navigation', 'common']);
<span>GitHub PRs</span> // ❌ WRONG
```
**Error messages with substitution:**
```tsx
// For error messages with dynamic content, use interpolation
const { t } = useTranslation(['errors']);
// errors.json: { "task": { "parseError": "Failed to parse: {{error}}" } }
<span>{t('errors:task.parseError', { error: errorMessage })}</span>
```
**When adding new UI text:**
1. Add the translation key to ALL language files (at minimum: `en/*.json` and `fr/*.json`)
2. Use `namespace:section.key` format (e.g., `navigation:items.githubPRs`)
+1 -1
View File
@@ -141,7 +141,7 @@ async def run_followup_planner(
if pending_subtasks:
# Reset the plan status to in_progress (in case planner didn't)
plan.reset_for_followup()
plan.save(plan_file)
await plan.async_save(plan_file)
print()
content = [
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""
JSON Recovery Utility
=====================
Detects and repairs corrupted JSON files in specs directories.
Usage:
python -m cli.recovery --project-dir /path/to/project --detect
python -m cli.recovery --project-dir /path/to/project --spec-id 004-feature --delete
python -m cli.recovery --project-dir /path/to/project --all --delete
"""
import argparse
import json
import sys
import uuid
from pathlib import Path
from cli.utils import find_specs_dir
def check_json_file(filepath: Path) -> tuple[bool, str | None]:
"""
Check if a JSON file is valid.
Returns:
(is_valid, error_message)
"""
try:
with open(filepath, encoding="utf-8") as f:
json.load(f)
return True, None
except json.JSONDecodeError as e:
return False, str(e)
except Exception as e:
return False, str(e)
def detect_corrupted_files(specs_dir: Path) -> list[tuple[Path, str]]:
"""
Scan specs directory recursively for corrupted JSON files.
Returns:
List of (filepath, error_message) tuples
"""
corrupted = []
if not specs_dir.exists():
return corrupted
# Recursively scan for JSON files (includes nested files like memory/*.json)
for json_file in specs_dir.rglob("*.json"):
is_valid, error = check_json_file(json_file)
if not is_valid:
# Type narrowing: error is str when is_valid is False
assert error is not None
corrupted.append((json_file, error))
return corrupted
def backup_corrupted_file(filepath: Path) -> bool:
"""
Backup a corrupted file by renaming it with a .corrupted suffix.
Args:
filepath: Path to the corrupted file
Returns:
True if backed up successfully, False otherwise
"""
try:
# Create backup before deleting
base_backup_path = filepath.with_suffix(f"{filepath.suffix}.corrupted")
backup_path = base_backup_path
# Handle existing backup files by generating unique name with UUID
if backup_path.exists():
# Use UUID for unique naming to avoid races
unique_suffix = uuid.uuid4().hex[:8]
backup_path = filepath.with_suffix(
f"{filepath.suffix}.corrupted.{unique_suffix}"
)
filepath.rename(backup_path)
print(f" [BACKUP] Moved corrupted file to: {backup_path}")
return True
except Exception as e:
print(f" [ERROR] Failed to backup file: {e}")
return False
def main() -> None:
parser = argparse.ArgumentParser(
description="Detect and repair corrupted JSON files in specs directories"
)
parser.add_argument(
"--project-dir",
type=Path,
default=Path.cwd(),
help="Project directory (default: current directory)",
)
parser.add_argument(
"--specs-dir",
type=Path,
help="Specs directory path (overrides auto-detection)",
)
parser.add_argument(
"--detect",
action="store_true",
help="Detect corrupted JSON files",
)
parser.add_argument(
"--spec-id",
type=str,
help="Specific spec ID to fix (e.g., 004-feature)",
)
parser.add_argument(
"--delete",
action="store_true",
help="Delete corrupted files (creates .corrupted backup)",
)
parser.add_argument(
"--all",
action="store_true",
help="Fix all corrupted files (requires --delete)",
)
args = parser.parse_args()
# Validate --all requires --delete
if args.all and not args.delete:
parser.error("--all requires --delete")
# Find specs directory
if args.specs_dir:
specs_dir = args.specs_dir
else:
specs_dir = find_specs_dir(args.project_dir)
print(f"[INFO] Scanning specs directory: {specs_dir}")
# Default to detect mode if no flags provided
if not args.detect and not args.delete:
args.detect = True
# Detect corrupted files (dry-run when detect-only, otherwise for deletion)
corrupted = detect_corrupted_files(specs_dir)
# Detect-only mode: show results and exit
if args.detect and not args.delete:
if not corrupted:
print("[OK] No corrupted JSON files found")
sys.exit(0)
print(f"\n[FOUND] {len(corrupted)} corrupted file(s):\n")
for filepath, error in corrupted:
print(f" - {filepath.relative_to(specs_dir.parent)}")
print(f" Error: {error}")
print()
# Exit with error code when corrupted files are found
sys.exit(1)
# Delete corrupted files
if args.delete:
if args.spec_id:
# Delete specific spec
spec_dir = (specs_dir / args.spec_id).resolve()
specs_dir_resolved = specs_dir.resolve()
# Validate path doesn't escape specs directory
if not spec_dir.is_relative_to(specs_dir_resolved):
print("[ERROR] Invalid spec ID: path traversal detected")
sys.exit(1)
if not spec_dir.exists():
print(f"[ERROR] Spec directory not found: {spec_dir}")
sys.exit(1)
print(f"[INFO] Processing spec: {args.spec_id}")
has_failures = False
for json_file in spec_dir.rglob("*.json"):
is_valid, error = check_json_file(json_file)
if not is_valid:
print(f" [CORRUPTED] {json_file.name}")
if not backup_corrupted_file(json_file):
has_failures = True
if has_failures:
sys.exit(1)
elif args.all:
# Delete all corrupted files
# Use the already-detected corrupted list, or re-scan if needed
if not corrupted:
corrupted = detect_corrupted_files(specs_dir)
if not corrupted:
print("[OK] No corrupted files to delete")
sys.exit(0)
print(f"\n[INFO] Backing up {len(corrupted)} corrupted file(s):\n")
has_failures = False
for filepath, _ in corrupted:
# backup_corrupted_file prints its own [BACKUP] message
if not backup_corrupted_file(filepath):
has_failures = True
if has_failures:
sys.exit(1)
else:
print("[ERROR] Must specify --spec-id or --all with --delete")
sys.exit(1)
if __name__ == "__main__":
main()
+16
View File
@@ -255,3 +255,19 @@ def get_project_dir(provided_dir: Path | None) -> Path:
project_dir = project_dir.parent.parent
return project_dir
def find_specs_dir(project_dir: Path) -> Path:
"""
Find the specs directory for a project.
Returns the '.auto-claude/specs' directory path.
The directory is guaranteed to exist (get_specs_dir calls init_auto_claude_dir).
Args:
project_dir: Project root directory
Returns:
Path to specs directory (always returns a valid Path)
"""
return get_specs_dir(project_dir)
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
Atomic File Write Utilities
============================
Synchronous utilities for atomic file writes to prevent corruption.
Uses temp file + os.replace() pattern which is atomic on POSIX systems
and atomic on Windows when source and destination are on the same volume.
Usage:
from core.file_utils import write_json_atomic
write_json_atomic("/path/to/file.json", {"key": "value"})
"""
import json
import logging
import os
import tempfile
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import IO, Any, Literal
@contextmanager
def atomic_write(
filepath: str | Path,
mode: Literal["w", "wb", "wt"] = "w",
encoding: str | None = "utf-8",
) -> Iterator[IO]:
"""
Atomic file write using temp file and rename.
Writes to .tmp file first, then atomically replaces target file
using os.replace() which is atomic on POSIX systems and same-volume Windows.
Note: This function supports both text and binary modes. For binary modes
(mode containing 'b'), encoding must be None.
Args:
filepath: Target file path
mode: File open mode (default: "w", text mode only)
encoding: File encoding for text modes, None for binary (default: "utf-8")
Example:
with atomic_write("/path/to/file.json") as f:
json.dump(data, f)
Yields:
File handle to temp file
"""
filepath = Path(filepath)
filepath.parent.mkdir(parents=True, exist_ok=True)
# Binary modes require encoding=None
actual_encoding = None if "b" in mode else encoding
# Create temp file in same directory for atomic rename
fd, tmp_path = tempfile.mkstemp(
dir=filepath.parent, prefix=f".{filepath.name}.tmp.", suffix=""
)
# Open temp file with requested mode
# If fdopen fails, close fd and clean up temp file
try:
f = os.fdopen(fd, mode, encoding=actual_encoding)
except Exception:
os.close(fd)
os.unlink(tmp_path)
raise
try:
with f:
yield f
except Exception:
# Clean up temp file on error (replace didn't happen yet)
try:
os.unlink(tmp_path)
except Exception as cleanup_err:
# Best-effort cleanup, ignore errors to not mask original exception
# Log cleanup failure for debugging (orphaned temp files may accumulate)
logging.warning(
f"Failed to cleanup temp file {tmp_path}: {cleanup_err}",
exc_info=True,
)
raise
else:
# Atomic replace - only runs if no exception was raised
# If os.replace itself fails, do NOT clean up (may be partially renamed)
os.replace(tmp_path, filepath)
def write_json_atomic(
filepath: str | Path,
data: Any,
indent: int = 2,
ensure_ascii: bool = False,
encoding: str = "utf-8",
) -> None:
"""
Write JSON data to file atomically.
This function prevents file corruption by:
1. Writing to a temporary file first
2. Only replacing the target file if the write succeeds
3. Using os.replace() for atomicity
Args:
filepath: Target file path
data: Data to serialize as JSON
indent: JSON indentation (default: 2)
ensure_ascii: Whether to escape non-ASCII characters (default: False)
encoding: File encoding (default: "utf-8")
Example:
write_json_atomic("/path/to/file.json", {"key": "value"})
"""
with atomic_write(filepath, "w", encoding=encoding) as f:
json.dump(data, f, indent=indent, ensure_ascii=ensure_ascii)
+51 -8
View File
@@ -7,11 +7,15 @@ Defines the complete implementation plan for a feature/task with progress
tracking, status management, and follow-up capabilities.
"""
import asyncio
import functools
import json
from dataclasses import dataclass, field
from dataclasses import dataclass, field, fields
from datetime import datetime
from pathlib import Path
from core.file_utils import write_json_atomic
from .enums import PhaseType, SubtaskStatus, WorkflowType
from .phase import Phase
from .subtask import Subtask
@@ -98,18 +102,57 @@ class ImplementationPlan:
qa_signoff=data.get("qa_signoff"),
)
def save(self, path: Path):
"""Save plan to JSON file."""
def _update_timestamps_and_status(self) -> None:
"""Update timestamps and status before saving.
Sets updated_at to now, initializes created_at if needed, and updates
status based on subtask completion.
"""
self.updated_at = datetime.now().isoformat()
if not self.created_at:
self.created_at = self.updated_at
# Auto-update status based on subtask completion
self.update_status_from_subtasks()
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(self.to_dict(), f, indent=2, ensure_ascii=False)
def save(self, path: Path) -> None:
"""Save plan to JSON file using atomic write to prevent corruption."""
self._update_timestamps_and_status()
# Use atomic write to prevent corruption on crash/interrupt
write_json_atomic(path, self.to_dict(), indent=2, ensure_ascii=False)
async def async_save(self, path: Path) -> None:
"""
Async version of save() - runs file I/O in thread pool to avoid blocking event loop.
Use this from async contexts (like agent sessions) to prevent blocking.
Restores in-memory state if the write fails.
"""
# Capture full state for potential rollback (handles future field additions)
old_state = self.to_dict()
# Update state and capture dict
self._update_timestamps_and_status()
data = self.to_dict()
# Run sync write in thread pool to avoid blocking event loop
loop = asyncio.get_running_loop()
partial_write = functools.partial(
write_json_atomic,
path,
data,
indent=2,
ensure_ascii=False,
)
try:
await loop.run_in_executor(None, partial_write)
except Exception:
# Restore full state from captured dict on write failure
# This reverts all fields modified by _update_timestamps_and_status()
restored = self.from_dict(old_state)
# Copy restored fields back to self (dataclass __init__ returns new instance)
for field in fields(self):
setattr(self, field.name, getattr(restored, field.name))
raise
def update_status_from_subtasks(self):
"""Update overall status and planStatus based on subtask completion state.
+37 -7
View File
@@ -2,7 +2,7 @@ import { app } from 'electron';
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, Dirent } from 'fs';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, ImplementationPlan, ReviewReason, PlanSubtask } from '../shared/types';
import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, TaskErrorInfo, ImplementationPlan, ReviewReason, PlanSubtask } from '../shared/types';
import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir } from '../shared/constants';
import { getAutoBuildPath, isInitialized } from './project-initializer';
import { getTaskWorktreeDir } from './worktree-paths';
@@ -384,6 +384,7 @@ export class ProjectStore {
// Try to read implementation plan
let plan: ImplementationPlan | null = null;
let parseError: TaskErrorInfo | null = null;
if (existsSync(planPath)) {
console.warn(`[ProjectStore] Loading implementation_plan.json for spec: ${dir.name} from ${location}`);
try {
@@ -397,7 +398,15 @@ export class ProjectStore {
subtaskCount: plan?.phases?.flatMap(p => p.subtasks || []).length || 0
});
} catch (err) {
console.error(`[ProjectStore] Failed to parse implementation_plan.json for ${dir.name}:`, err);
const errorMessage = err instanceof Error ? err.message : String(err);
parseError = {
key: 'errors:task.parseImplementationPlan',
meta: {
specId: dir.name,
error: errorMessage.slice(0, 500)
}
};
console.error(`[ProjectStore] Failed to parse implementation_plan.json for ${dir.name}:`, errorMessage);
}
} else {
console.warn(`[ProjectStore] No implementation_plan.json found for spec: ${dir.name} at ${planPath}`);
@@ -455,7 +464,21 @@ export class ProjectStore {
}
// Determine task status and review reason from plan
const { status, reviewReason } = this.determineTaskStatusAndReason(plan, specPath, metadata);
// If there's a parse error, override to error status
let finalStatus: TaskStatus;
let finalDescription = description;
let finalReviewReason: ReviewReason | undefined = undefined;
let finalErrorInfo: TaskErrorInfo | undefined = undefined;
if (parseError) {
finalStatus = 'error';
finalErrorInfo = parseError;
console.error(`[ProjectStore] Creating error task for ${dir.name}:`, parseError);
} else {
const { status, reviewReason } = this.determineTaskStatusAndReason(plan, specPath, metadata);
finalStatus = status;
finalReviewReason = reviewReason;
}
// Extract subtasks from plan (handle both 'subtasks' and 'chunks' naming)
const subtasks = plan?.phases?.flatMap((phase) => {
@@ -498,12 +521,13 @@ export class ProjectStore {
specId: dir.name,
projectId,
title,
description,
status,
reviewReason,
description: finalDescription,
status: finalStatus,
subtasks,
logs: [],
metadata,
...(finalReviewReason !== undefined && { reviewReason: finalReviewReason }),
...(finalErrorInfo !== undefined && { errorInfo: finalErrorInfo }),
stagedInMainProject,
stagedAt,
location, // Add location metadata (main vs worktree)
@@ -583,7 +607,8 @@ export class ProjectStore {
'human_review': 'human_review',
'ai_review': 'ai_review',
'pr_created': 'pr_created', // PR has been created for this task
'backlog': 'backlog'
'backlog': 'backlog',
'error': 'error' // Preserves error status from JSON parse failures
};
const storedStatus = statusMap[plan.status];
@@ -597,6 +622,11 @@ export class ProjectStore {
return { status: 'pr_created' };
}
// If task has an error status, always respect that (from JSON parse failures)
if (storedStatus === 'error') {
return { status: 'error' };
}
// 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
@@ -19,7 +19,7 @@ import {
sortableKeyboardCoordinates,
verticalListSortingStrategy
} from '@dnd-kit/sortable';
import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive, RefreshCw } from 'lucide-react';
import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive, RefreshCw, AlertCircle } from 'lucide-react';
import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
@@ -145,6 +145,12 @@ const getEmptyStateContent = (status: TaskStatus, t: (key: string) => string): {
message: t('kanban.emptyDone'),
subtext: t('kanban.emptyDoneHint')
};
case 'error':
return {
icon: <AlertCircle className="h-6 w-6 text-destructive/50" />,
message: t('kanban.emptyError'),
subtext: t('kanban.emptyErrorHint')
};
default:
return {
icon: <Inbox className="h-6 w-6 text-muted-foreground/50" />,
@@ -205,6 +211,8 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli
return 'column-human-review';
case 'done':
return 'column-done';
case 'error':
return 'column-error';
default:
return 'border-t-muted-foreground/30';
}
@@ -384,6 +392,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
const grouped: Record<typeof TASK_STATUS_COLUMNS[number], Task[]> = {
error: [],
backlog: [],
in_progress: [],
ai_review: [],
@@ -568,7 +577,7 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
className="gap-2 text-muted-foreground hover:text-foreground"
>
<RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
{isRefreshing ? 'Refreshing...' : 'Refresh Tasks'}
{isRefreshing ? t('common:buttons.refreshing') : t('tasks:refreshTasks')}
</Button>
</div>
)}
@@ -267,6 +267,8 @@ export const TaskCard = memo(function TaskCard({ task, onClick, onStatusChange }
return 'success';
case 'done':
return 'success';
case 'error':
return 'destructive';
default:
return 'secondary';
}
@@ -284,6 +286,8 @@ export const TaskCard = memo(function TaskCard({ task, onClick, onStatusChange }
return t('columns.pr_created');
case 'done':
return t('status.complete');
case 'error':
return t('columns.error');
default:
return t('labels.pending');
}
@@ -231,11 +231,21 @@ export const useTaskStore = create<TaskState>((set, get) => ({
// 1. Subtasks array is properly populated (not empty)
// 2. All subtasks are actually completed (for 'done' and 'ai_review' statuses)
const hasSubtasks = subtasks.length > 0;
const terminalStatuses: TaskStatus[] = ['human_review', 'pr_created', 'done'];
const terminalStatuses: TaskStatus[] = ['human_review', 'pr_created', 'done', 'error'];
// If task is currently in a terminal status, validate subtasks before allowing downgrade
// This prevents flip-flop when plan file is written with incomplete data
const shouldBlockTerminalTransition = (newStatus: TaskStatus): boolean => {
// Allow recovery from error status to backlog or in_progress
if (t.status === 'error' && (newStatus === 'backlog' || newStatus === 'in_progress')) {
return false; // Allow error recovery transitions
}
// Error status doesn't require completion validation (it's a failure state)
if (newStatus === 'error') {
return false; // Allow transitions to error without completion check
}
// Block if: moving to terminal status but subtasks indicate incomplete work
if (terminalStatuses.includes(newStatus) || newStatus === 'ai_review') {
// For ai_review, all subtasks must be completed
@@ -1156,6 +1156,10 @@ body {
border-top-color: var(--success);
}
.column-error {
border-top-color: var(--destructive);
}
/* Progress bar working animation - subtle glow sweep */
@keyframes progress-glow-sweep {
0% {
+7 -2
View File
@@ -9,6 +9,7 @@
// Task status columns in Kanban board order
export const TASK_STATUS_COLUMNS = [
'error',
'backlog',
'in_progress',
'ai_review',
@@ -16,9 +17,12 @@ export const TASK_STATUS_COLUMNS = [
'done'
] as const;
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<string, string> = {
export const TASK_STATUS_LABELS: Record<TaskStatusColumn | 'pr_created', string> = {
error: 'columns.error',
backlog: 'columns.backlog',
in_progress: 'columns.in_progress',
ai_review: 'columns.ai_review',
@@ -29,7 +33,8 @@ export const TASK_STATUS_LABELS: Record<string, string> = {
// Status colors for UI
// Note: pr_created maps to 'done' column in Kanban view (see KanbanBoard.tsx)
export const TASK_STATUS_COLORS: Record<string, string> = {
export const TASK_STATUS_COLORS: Record<TaskStatusColumn | 'pr_created', string> = {
error: 'bg-destructive/10 text-destructive',
backlog: 'bg-muted text-muted-foreground',
in_progress: 'bg-info/10 text-info',
ai_review: 'bg-warning/10 text-warning',
+7 -3
View File
@@ -12,6 +12,7 @@ import enDialogs from './locales/en/dialogs.json';
import enGitlab from './locales/en/gitlab.json';
import enTaskReview from './locales/en/taskReview.json';
import enTerminal from './locales/en/terminal.json';
import enErrors from './locales/en/errors.json';
// Import French translation resources
import frCommon from './locales/fr/common.json';
@@ -24,6 +25,7 @@ import frDialogs from './locales/fr/dialogs.json';
import frGitlab from './locales/fr/gitlab.json';
import frTaskReview from './locales/fr/taskReview.json';
import frTerminal from './locales/fr/terminal.json';
import frErrors from './locales/fr/errors.json';
export const defaultNS = 'common';
@@ -38,7 +40,8 @@ export const resources = {
dialogs: enDialogs,
gitlab: enGitlab,
taskReview: enTaskReview,
terminal: enTerminal
terminal: enTerminal,
errors: enErrors
},
fr: {
common: frCommon,
@@ -50,7 +53,8 @@ export const resources = {
dialogs: frDialogs,
gitlab: frGitlab,
taskReview: frTaskReview,
terminal: frTerminal
terminal: frTerminal,
errors: frErrors
}
} as const;
@@ -61,7 +65,7 @@ i18n
lng: 'en', // Default language (will be overridden by settings)
fallbackLng: 'en',
defaultNS,
ns: ['common', 'navigation', 'settings', 'tasks', 'welcome', 'onboarding', 'dialogs', 'gitlab', 'taskReview', 'terminal'],
ns: ['common', 'navigation', 'settings', 'tasks', 'welcome', 'onboarding', 'dialogs', 'gitlab', 'taskReview', 'terminal', 'errors'],
interpolation: {
escapeValue: false // React already escapes values
},
@@ -72,6 +72,7 @@
"start": "Start",
"stop": "Stop",
"refresh": "Refresh",
"refreshing": "Refreshing...",
"merge": "Merge",
"discard": "Discard",
"switch": "Switch",
@@ -0,0 +1,5 @@
{
"task": {
"parseImplementationPlan": "Failed to parse implementation_plan.json for {{specId}}: {{error}}"
}
}
@@ -1,4 +1,5 @@
{
"refreshTasks": "Refresh Tasks",
"status": {
"backlog": "Backlog",
"todo": "To Do",
@@ -52,6 +53,7 @@
"description": "Create your first task to get started"
},
"columns": {
"error": "Error",
"backlog": "Planning",
"in_progress": "In Progress",
"ai_review": "AI Review",
@@ -70,6 +72,8 @@
"emptyHumanReviewHint": "Tasks await your approval here",
"emptyDone": "No completed tasks",
"emptyDoneHint": "Approved tasks appear here",
"emptyError": "No error tasks",
"emptyErrorHint": "Error tasks will appear here when parsing fails",
"emptyDefault": "No tasks",
"dropHere": "Drop here",
"showArchived": "Show archived",
@@ -81,7 +85,8 @@
"worktreeCleanupStaged": "This task has been staged and has a worktree. Would you like to clean up the worktree?",
"worktreeCleanupNotStaged": "This task has a worktree with changes that have not been merged. Delete the worktree to mark as done, or cancel to review the changes first.",
"keepWorktree": "Keep Worktree",
"deleteWorktree": "Delete Worktree & Mark Done"
"deleteWorktree": "Delete Worktree & Mark Done",
"refreshTasks": "Refresh Tasks"
},
"execution": {
"phases": {
@@ -72,6 +72,7 @@
"start": "Démarrer",
"stop": "Arrêter",
"refresh": "Actualiser",
"refreshing": "Actualisation...",
"merge": "Fusionner",
"discard": "Abandonner",
"switch": "Changer",
@@ -0,0 +1,5 @@
{
"task": {
"parseImplementationPlan": "Échec de l'analyse du fichier implementation_plan.json pour {{specId}} : {{error}}"
}
}
@@ -1,4 +1,5 @@
{
"refreshTasks": "Actualiser les tâches",
"status": {
"backlog": "Backlog",
"todo": "À faire",
@@ -52,6 +53,7 @@
"description": "Créez votre première tâche pour commencer"
},
"columns": {
"error": "Erreur",
"backlog": "Planification",
"in_progress": "En cours",
"ai_review": "Révision IA",
@@ -70,6 +72,8 @@
"emptyHumanReviewHint": "Les tâches attendent votre approbation ici",
"emptyDone": "Aucune tâche terminée",
"emptyDoneHint": "Les tâches approuvées apparaissent ici",
"emptyError": "Aucune tâche en erreur",
"emptyErrorHint": "Les tâches en erreur apparaîtront ici en cas d'échec d'analyse",
"emptyDefault": "Aucune tâche",
"dropHere": "Déposer ici",
"showArchived": "Afficher les archivées",
@@ -81,7 +85,8 @@
"worktreeCleanupStaged": "Cette tâche a été préparée et possède un worktree. Voulez-vous nettoyer le worktree ?",
"worktreeCleanupNotStaged": "Cette tâche possède un worktree avec des changements non fusionnés. Supprimez le worktree pour marquer comme terminé, ou annulez pour réviser les changements d'abord.",
"keepWorktree": "Garder le Worktree",
"deleteWorktree": "Supprimer le Worktree & Marquer Terminé"
"deleteWorktree": "Supprimer le Worktree & Marquer Terminé",
"refreshTasks": "Actualiser les tâches"
},
"execution": {
"phases": {
+8 -1
View File
@@ -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';
// Reason why a task is in human_review status
// - 'completed': All subtasks done and QA passed, ready for final approval/merge
@@ -240,6 +240,12 @@ export interface TaskMetadata {
archivedInVersion?: string; // Version in which task was archived (from changelog)
}
// Structured error information for tasks with parse errors
export interface TaskErrorInfo {
key: string; // Translation key (e.g., 'errors:task.parseImplementationPlan')
meta?: { specId?: string; error?: string }; // Error context for substitution in translation
}
export interface Task {
id: string;
specId: string;
@@ -252,6 +258,7 @@ export interface Task {
qaReport?: QAReport;
logs: string[];
metadata?: TaskMetadata; // Rich metadata from ideation or manual entry
errorInfo?: TaskErrorInfo; // Structured error information for i18n (set when status is 'error')
executionProgress?: ExecutionProgress; // Real-time execution progress
releasedInVersion?: string; // Version in which this task was released
stagedInMainProject?: boolean; // True if changes were staged to main project (worktree merged with --no-commit)