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:
@@ -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`)
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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% {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user