Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e06b15d3d | |||
| c2b287c02c | |||
| 4a8c94538c |
@@ -13,7 +13,9 @@ import re
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from context.constants import SKIP_DIRS
|
||||
from core.client import create_client
|
||||
from core.file_utils import write_json_atomic
|
||||
from linear_updater import (
|
||||
LinearTaskState,
|
||||
is_linear_enabled,
|
||||
@@ -84,6 +86,7 @@ from .memory_manager import debug_memory_system_status, get_graphiti_context
|
||||
from .session import post_session_processing, run_agent_session
|
||||
from .utils import (
|
||||
find_phase_for_subtask,
|
||||
find_subtask_in_plan,
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
@@ -97,8 +100,383 @@ logger = logging.getLogger(__name__)
|
||||
# FILE VALIDATION UTILITIES
|
||||
# =============================================================================
|
||||
|
||||
# Directories to exclude from file path search — extends context.constants.SKIP_DIRS
|
||||
_EXCLUDE_DIRS = frozenset(SKIP_DIRS | {".auto-claude", ".tox", "out"})
|
||||
|
||||
def validate_subtask_files(subtask: dict, project_dir: Path) -> dict:
|
||||
|
||||
def _build_file_index(
|
||||
project_dir: Path, suffixes: set[str]
|
||||
) -> dict[str, list[tuple[str, Path]]]:
|
||||
"""
|
||||
Build an index of project files grouped by basename, scanning the tree once.
|
||||
|
||||
Also indexes index.{ext} files under their parent directory name as a
|
||||
secondary key (e.g., api/index.ts is indexed under both "index.ts" and
|
||||
"api" as directory-stem).
|
||||
|
||||
Args:
|
||||
project_dir: Root directory of the project
|
||||
suffixes: File extensions to index (e.g., {".ts", ".tsx"})
|
||||
|
||||
Returns:
|
||||
Dict mapping basename -> list of (relative_path_str, Path(relative_path))
|
||||
"""
|
||||
index: dict[str, list[tuple[str, Path]]] = {}
|
||||
resolved_str = str(project_dir.resolve())
|
||||
|
||||
for root, dirs, files in os.walk(project_dir.resolve()):
|
||||
dirs[:] = [d for d in dirs if d not in _EXCLUDE_DIRS]
|
||||
|
||||
for filename in files:
|
||||
ext_idx = filename.rfind(".")
|
||||
if ext_idx == -1:
|
||||
continue
|
||||
file_suffix = filename[ext_idx:]
|
||||
if file_suffix not in suffixes:
|
||||
continue
|
||||
|
||||
full_path = os.path.join(root, filename)
|
||||
rel_str = os.path.relpath(full_path, resolved_str).replace(os.sep, "/")
|
||||
rel_path = Path(rel_str)
|
||||
|
||||
# Index by basename
|
||||
index.setdefault(filename, []).append((rel_str, rel_path))
|
||||
|
||||
# Also index index.{ext} files by parent dir name (for stem matching)
|
||||
stem_part = filename[:ext_idx]
|
||||
if stem_part == "index":
|
||||
dir_name = os.path.basename(root)
|
||||
key = f"__dir_stem__:{dir_name}{file_suffix}"
|
||||
index.setdefault(key, []).append((rel_str, rel_path))
|
||||
|
||||
return index
|
||||
|
||||
|
||||
def _score_and_select(candidates: list[tuple[str, float]]) -> str | None:
|
||||
"""
|
||||
Select the best candidate from a scored list of (path, score) pairs.
|
||||
|
||||
Requires a minimum score of 8.0 and a gap of at least 3.0 from the
|
||||
runner-up to avoid ambiguous matches.
|
||||
|
||||
Args:
|
||||
candidates: List of (relative_path, score) tuples
|
||||
|
||||
Returns:
|
||||
Best path if unambiguous, None otherwise
|
||||
"""
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
candidates.sort(key=lambda x: x[1], reverse=True)
|
||||
best_path, best_score = candidates[0]
|
||||
|
||||
if best_score < 8.0:
|
||||
return None
|
||||
|
||||
if len(candidates) > 1:
|
||||
runner_up_score = candidates[1][1]
|
||||
if best_score - runner_up_score < 3.0:
|
||||
return None
|
||||
|
||||
return best_path
|
||||
|
||||
|
||||
def _find_correct_path_indexed(
|
||||
missing_path: str,
|
||||
parent_parts: tuple[str, ...],
|
||||
file_index: dict[str, list[tuple[str, Path]]],
|
||||
) -> str | None:
|
||||
"""
|
||||
Find the correct path using a pre-built file index (no tree walk needed).
|
||||
|
||||
Args:
|
||||
missing_path: The incorrect file path from the plan
|
||||
parent_parts: Parent directory parts of the missing path
|
||||
file_index: Index built by _build_file_index
|
||||
|
||||
Returns:
|
||||
Corrected relative path, or None if no good match found
|
||||
"""
|
||||
missing = Path(missing_path)
|
||||
basename = missing.name
|
||||
stem = missing.stem
|
||||
suffix = missing.suffix
|
||||
|
||||
if not suffix:
|
||||
return None
|
||||
|
||||
candidates: list[tuple[str, float]] = []
|
||||
|
||||
# Strategy 1: Exact basename match
|
||||
for rel_str, rel_path in file_index.get(basename, []):
|
||||
score = 10.0
|
||||
candidate_parts = rel_path.parent.parts
|
||||
for i, part in enumerate(parent_parts):
|
||||
if i < len(candidate_parts) and candidate_parts[i] == part:
|
||||
score += 3.0
|
||||
depth_diff = abs(len(candidate_parts) - len(parent_parts))
|
||||
score -= 0.5 * depth_diff
|
||||
candidates.append((rel_str, score))
|
||||
|
||||
# Strategy 2: index.{ext} in directory matching stem
|
||||
stem_key = f"__dir_stem__:{stem}{suffix}"
|
||||
for rel_str, rel_path in file_index.get(stem_key, []):
|
||||
score = 8.0
|
||||
candidate_parts = rel_path.parent.parts
|
||||
for i, part in enumerate(parent_parts):
|
||||
if i < len(candidate_parts) and candidate_parts[i] == part:
|
||||
score += 3.0
|
||||
depth_diff = abs(len(candidate_parts) - len(parent_parts))
|
||||
score -= 0.5 * depth_diff
|
||||
candidates.append((rel_str, score))
|
||||
|
||||
return _score_and_select(candidates)
|
||||
|
||||
|
||||
def _find_correct_path(missing_path: str, project_dir: Path) -> str | None:
|
||||
"""
|
||||
Attempt to find the correct path for a missing file using fuzzy matching.
|
||||
|
||||
Strategies:
|
||||
1. Same basename in nearby directory
|
||||
2. index.{ext} pattern (e.g., preload/api.ts -> preload/api/index.ts)
|
||||
|
||||
Uses os.walk with directory pruning to avoid traversing into node_modules,
|
||||
.git, dist, etc. — unlike Path.rglob which traverses everything then filters.
|
||||
|
||||
Args:
|
||||
missing_path: The incorrect file path from the plan
|
||||
project_dir: Root directory of the project
|
||||
|
||||
Returns:
|
||||
Corrected relative path, or None if no good match found
|
||||
"""
|
||||
missing = Path(missing_path)
|
||||
basename = missing.name
|
||||
stem = missing.stem
|
||||
suffix = missing.suffix
|
||||
parent_parts = missing.parent.parts
|
||||
|
||||
if not suffix:
|
||||
return None
|
||||
|
||||
candidates: list[tuple[str, float]] = []
|
||||
resolved_project = project_dir.resolve()
|
||||
resolved_str = str(resolved_project)
|
||||
|
||||
# os.walk with pruning: modify dirs in-place to skip excluded directories
|
||||
for root, dirs, files in os.walk(resolved_project):
|
||||
dirs[:] = [d for d in dirs if d not in _EXCLUDE_DIRS]
|
||||
|
||||
for filename in files:
|
||||
if not filename.endswith(suffix):
|
||||
continue
|
||||
|
||||
full_path = os.path.join(root, filename)
|
||||
rel_str = os.path.relpath(full_path, resolved_str).replace(os.sep, "/")
|
||||
rel = Path(rel_str)
|
||||
|
||||
score = 0.0
|
||||
|
||||
# Strategy 1: Exact basename match
|
||||
if filename == basename:
|
||||
score += 10.0
|
||||
# Strategy 2: index.{ext} in directory matching stem
|
||||
elif filename == f"index{suffix}" and os.path.basename(root) == stem:
|
||||
score += 8.0
|
||||
else:
|
||||
continue
|
||||
|
||||
# Bonus: shared parent directory segments
|
||||
candidate_parts = rel.parent.parts
|
||||
for i, part in enumerate(parent_parts):
|
||||
if i < len(candidate_parts) and candidate_parts[i] == part:
|
||||
score += 3.0
|
||||
|
||||
# Penalty: depth difference
|
||||
depth_diff = abs(len(candidate_parts) - len(parent_parts))
|
||||
score -= 0.5 * depth_diff
|
||||
|
||||
candidates.append((rel_str, score))
|
||||
|
||||
return _score_and_select(candidates)
|
||||
|
||||
|
||||
def _auto_correct_subtask_files(
|
||||
subtask: dict,
|
||||
missing_files: list[str],
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Attempt to auto-correct missing file paths in a subtask.
|
||||
|
||||
Corrects paths in-memory AND persists changes to implementation_plan.json.
|
||||
|
||||
Args:
|
||||
subtask: Subtask dictionary containing files_to_modify
|
||||
missing_files: List of file paths that don't exist
|
||||
project_dir: Root directory of the project
|
||||
spec_dir: Spec directory containing implementation_plan.json
|
||||
|
||||
Returns:
|
||||
List of file paths that could NOT be corrected
|
||||
"""
|
||||
corrections: dict[str, str] = {}
|
||||
still_missing: list[str] = []
|
||||
|
||||
# Build file index once for all missing files (avoids repeated os.walk)
|
||||
suffixes_needed: set[str] = set()
|
||||
for missing_path in missing_files:
|
||||
suffix = Path(missing_path).suffix
|
||||
if suffix:
|
||||
suffixes_needed.add(suffix)
|
||||
file_index = (
|
||||
_build_file_index(project_dir, suffixes_needed) if suffixes_needed else {}
|
||||
)
|
||||
|
||||
for missing_path in missing_files:
|
||||
missing = Path(missing_path)
|
||||
corrected = _find_correct_path_indexed(
|
||||
missing_path, missing.parent.parts, file_index
|
||||
)
|
||||
if corrected:
|
||||
corrections[missing_path] = corrected
|
||||
logger.info(f"Auto-corrected file path: {missing_path} -> {corrected}")
|
||||
print_status(f"Auto-corrected: {missing_path} -> {corrected}", "success")
|
||||
else:
|
||||
still_missing.append(missing_path)
|
||||
|
||||
if not corrections:
|
||||
return still_missing
|
||||
|
||||
# Update subtask in-memory
|
||||
files_to_modify = subtask.get("files_to_modify", [])
|
||||
subtask["files_to_modify"] = [corrections.get(f, f) for f in files_to_modify]
|
||||
|
||||
# Persist corrections to implementation_plan.json
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if plan_file.exists():
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
subtask_id = subtask.get("id")
|
||||
if subtask_id is not None:
|
||||
plan_subtask = find_subtask_in_plan(plan, subtask_id)
|
||||
if plan_subtask:
|
||||
plan_files = plan_subtask.get("files_to_modify", [])
|
||||
plan_subtask["files_to_modify"] = [
|
||||
corrections.get(f, f) for f in plan_files
|
||||
]
|
||||
|
||||
write_json_atomic(plan_file, plan)
|
||||
logger.info(
|
||||
f"Persisted {len(corrections)} path correction(s) to implementation_plan.json"
|
||||
)
|
||||
except (OSError, TypeError, ValueError) as e:
|
||||
logger.warning(f"Failed to persist path corrections: {e}")
|
||||
|
||||
return still_missing
|
||||
|
||||
|
||||
def _validate_plan_file_paths(spec_dir: Path, project_dir: Path) -> str | None:
|
||||
"""
|
||||
Validate all file paths in the implementation plan after planning.
|
||||
|
||||
Builds a file index once, then checks all paths across all subtasks against it.
|
||||
Attempts auto-correction for missing paths. Returns a retry context string for
|
||||
the planner if uncorrectable paths remain, or None if all paths are valid.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory containing implementation_plan.json
|
||||
project_dir: Root directory of the project
|
||||
|
||||
Returns:
|
||||
Retry context string if issues remain, None if all OK
|
||||
"""
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
resolved_project = project_dir.resolve()
|
||||
|
||||
# First pass: collect all missing files and their suffixes
|
||||
missing_entries: list[
|
||||
tuple[list[str], int, str]
|
||||
] = [] # (subtask_files_list, index, path)
|
||||
suffixes_needed: set[str] = set()
|
||||
|
||||
for phase in plan.get("phases", []):
|
||||
for subtask in phase.get("subtasks", []):
|
||||
files = subtask.get("files_to_modify", [])
|
||||
for i, file_path in enumerate(files):
|
||||
full_path = (resolved_project / file_path).resolve()
|
||||
if not full_path.is_relative_to(resolved_project):
|
||||
continue
|
||||
if full_path.exists():
|
||||
continue
|
||||
|
||||
missing = Path(file_path)
|
||||
if missing.suffix:
|
||||
suffixes_needed.add(missing.suffix)
|
||||
missing_entries.append((files, i, file_path))
|
||||
|
||||
if not missing_entries:
|
||||
return None
|
||||
|
||||
# Build index once for all needed suffixes
|
||||
file_index = _build_file_index(project_dir, suffixes_needed)
|
||||
|
||||
all_missing: list[str] = []
|
||||
corrections_made = 0
|
||||
|
||||
for files_list, idx, file_path in missing_entries:
|
||||
missing = Path(file_path)
|
||||
corrected = _find_correct_path_indexed(
|
||||
file_path, missing.parent.parts, file_index
|
||||
)
|
||||
if corrected:
|
||||
files_list[idx] = corrected
|
||||
corrections_made += 1
|
||||
logger.info(f"Post-plan auto-corrected: {file_path} -> {corrected}")
|
||||
print_status(f"Auto-corrected: {file_path} -> {corrected}", "success")
|
||||
else:
|
||||
all_missing.append(file_path)
|
||||
|
||||
# Persist any corrections that were made
|
||||
if corrections_made > 0:
|
||||
try:
|
||||
write_json_atomic(plan_file, plan)
|
||||
logger.info(f"Persisted {corrections_made} post-plan path correction(s)")
|
||||
except (OSError, TypeError, ValueError) as e:
|
||||
logger.warning(f"Failed to persist post-plan corrections: {e}")
|
||||
|
||||
if not all_missing:
|
||||
return None
|
||||
|
||||
return (
|
||||
"## FILE PATH VALIDATION ERRORS\n\n"
|
||||
"The following files referenced in your implementation plan do NOT exist "
|
||||
"and could not be auto-corrected:\n"
|
||||
+ "\n".join(f"- `{p}`" for p in all_missing)
|
||||
+ "\n\nPlease fix these file paths in the `implementation_plan.json`.\n"
|
||||
"Use the project's actual file structure to find the correct paths.\n"
|
||||
"Common issues: wrong directory nesting, missing index files "
|
||||
"(e.g., `dir/file.ts` should be `dir/file/index.ts`)."
|
||||
)
|
||||
|
||||
|
||||
def validate_subtask_files(
|
||||
subtask: dict, project_dir: Path, spec_dir: Path | None = None
|
||||
) -> dict:
|
||||
"""
|
||||
Validate all files_to_modify exist before subtask execution.
|
||||
|
||||
@@ -136,6 +514,15 @@ def validate_subtask_files(subtask: dict, project_dir: Path) -> dict:
|
||||
}
|
||||
|
||||
if missing_files:
|
||||
# Attempt auto-correction if spec_dir is provided
|
||||
if spec_dir:
|
||||
still_missing = _auto_correct_subtask_files(
|
||||
subtask, missing_files, project_dir, spec_dir
|
||||
)
|
||||
if not still_missing:
|
||||
return {"success": True, "missing_files": [], "invalid_paths": []}
|
||||
missing_files = still_missing
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Planned files do not exist: {', '.join(missing_files)}",
|
||||
@@ -685,7 +1072,10 @@ async def run_autonomous_agent(
|
||||
|
||||
# Validate that all files_to_modify exist before attempting execution
|
||||
# This prevents infinite retry loops when implementation plan references non-existent files
|
||||
validation_result = validate_subtask_files(next_subtask, project_dir)
|
||||
# Pass spec_dir to enable auto-correction of wrong paths
|
||||
validation_result = validate_subtask_files(
|
||||
next_subtask, project_dir, spec_dir
|
||||
)
|
||||
if not validation_result["success"]:
|
||||
# File validation failed - record error and skip session
|
||||
error_msg = validation_result["error"]
|
||||
@@ -719,6 +1109,11 @@ async def run_autonomous_agent(
|
||||
subtask_id,
|
||||
f"File validation failed after {attempt_count} attempts: {error_msg}",
|
||||
)
|
||||
emit_phase(
|
||||
ExecutionPhase.FAILED,
|
||||
f"Subtask {subtask_id} stuck: file validation failed",
|
||||
subtask=subtask_id,
|
||||
)
|
||||
print_status(
|
||||
f"Subtask {subtask_id} marked as STUCK after {attempt_count} failed validation attempts",
|
||||
"error",
|
||||
@@ -812,8 +1207,28 @@ async def run_autonomous_agent(
|
||||
if is_planning_phase and status != "error":
|
||||
valid, errors = _validate_and_fix_implementation_plan()
|
||||
if valid:
|
||||
plan_validated = True
|
||||
planning_retry_context = None
|
||||
# Fix 5: Validate file paths in the newly created plan
|
||||
path_issues = _validate_plan_file_paths(spec_dir, project_dir)
|
||||
if (
|
||||
path_issues
|
||||
and planning_validation_failures < max_planning_validation_retries
|
||||
):
|
||||
planning_validation_failures += 1
|
||||
planning_retry_context = path_issues
|
||||
print_status(
|
||||
"Plan has invalid file paths - retrying planner",
|
||||
"warning",
|
||||
)
|
||||
first_run = True
|
||||
status = "continue"
|
||||
else:
|
||||
if path_issues:
|
||||
logger.warning(
|
||||
f"Plan has uncorrectable file paths after "
|
||||
f"{planning_validation_failures} retries - proceeding anyway"
|
||||
)
|
||||
plan_validated = True
|
||||
planning_retry_context = None
|
||||
else:
|
||||
planning_validation_failures += 1
|
||||
if planning_validation_failures >= max_planning_validation_retries:
|
||||
@@ -871,6 +1286,11 @@ async def run_autonomous_agent(
|
||||
recovery_manager.mark_subtask_stuck(
|
||||
subtask_id, f"Failed after {attempt_count} attempts"
|
||||
)
|
||||
emit_phase(
|
||||
ExecutionPhase.FAILED,
|
||||
f"Subtask {subtask_id} stuck after {attempt_count} attempts",
|
||||
subtask=subtask_id,
|
||||
)
|
||||
print()
|
||||
print_status(
|
||||
f"Subtask {subtask_id} marked as STUCK after {attempt_count} attempts",
|
||||
@@ -1230,4 +1650,24 @@ async def run_autonomous_agent(
|
||||
if completed == total:
|
||||
status_manager.update(state=BuildState.COMPLETE)
|
||||
else:
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
# Check if all remaining subtasks are stuck — if so, this is an error, not a pause
|
||||
all_remaining_stuck = False
|
||||
if stuck_subtasks:
|
||||
stuck_ids = {s["subtask_id"] for s in stuck_subtasks}
|
||||
plan = load_implementation_plan(spec_dir)
|
||||
if plan:
|
||||
all_remaining_stuck = True
|
||||
for phase in plan.get("phases", []):
|
||||
for s in phase.get("subtasks", []):
|
||||
if s.get("status") != "completed":
|
||||
if s.get("id") not in stuck_ids:
|
||||
all_remaining_stuck = False
|
||||
break
|
||||
if not all_remaining_stuck:
|
||||
break
|
||||
|
||||
if all_remaining_stuck and stuck_subtasks:
|
||||
emit_phase(ExecutionPhase.FAILED, "All remaining subtasks are stuck")
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
else:
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
|
||||
@@ -454,8 +454,11 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
|
||||
str(phase_id_raw) if phase_id_raw is not None else f"unknown:{i}"
|
||||
)
|
||||
subtasks = phase.get("subtasks", phase.get("chunks", []))
|
||||
# Stuck subtasks count as "resolved" for phase dependency purposes.
|
||||
# This prevents one stuck subtask from blocking all downstream phases.
|
||||
phase_complete[phase_id_key] = all(
|
||||
s.get("status") == "completed" for s in subtasks
|
||||
s.get("status") == "completed" or s.get("id") in stuck_subtask_ids
|
||||
for s in subtasks
|
||||
)
|
||||
|
||||
# Find next available subtask
|
||||
|
||||
@@ -1062,6 +1062,52 @@ export function registerTaskExecutionHandlers(
|
||||
}
|
||||
|
||||
console.log(`[Recovery] Total ${totalResetCount} subtask(s) reset across all locations`);
|
||||
|
||||
// Clear attempt_history.json to break infinite recovery loops.
|
||||
// Without this, the backend re-reads stuck markers from attempt_history
|
||||
// and immediately re-stucks the same subtasks after recovery.
|
||||
const specDirsToClean = new Set<string>([specDir]);
|
||||
if (mainSpecDir !== specDir) specDirsToClean.add(mainSpecDir);
|
||||
if (worktreeSpecDir && worktreeSpecDir !== specDir) specDirsToClean.add(worktreeSpecDir);
|
||||
|
||||
for (const dir of specDirsToClean) {
|
||||
const attemptHistoryPath = path.join(dir, 'memory', 'attempt_history.json');
|
||||
const historyContent = safeReadFileSync(attemptHistoryPath);
|
||||
if (!historyContent) continue;
|
||||
|
||||
try {
|
||||
const history = JSON.parse(historyContent);
|
||||
|
||||
// Collect stuck subtask IDs before clearing
|
||||
const stuckIds = new Set<string>(
|
||||
(history.stuck_subtasks || [])
|
||||
.map((s: { subtask_id?: string }) => s.subtask_id)
|
||||
.filter((id: string | undefined): id is string => Boolean(id))
|
||||
);
|
||||
|
||||
// Clear stuck_subtasks array
|
||||
history.stuck_subtasks = [];
|
||||
|
||||
// Reset attempt entries for previously-stuck subtasks
|
||||
if (history.subtasks && stuckIds.size > 0) {
|
||||
for (const stuckId of stuckIds) {
|
||||
if (history.subtasks[stuckId]) {
|
||||
history.subtasks[stuckId] = { attempts: [], status: 'pending' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
history.metadata = {
|
||||
...history.metadata,
|
||||
last_updated: new Date().toISOString()
|
||||
};
|
||||
|
||||
writeFileAtomicSync(attemptHistoryPath, JSON.stringify(history, null, 2));
|
||||
console.log(`[Recovery] Cleared attempt_history.json at: ${dir} (reset ${stuckIds.size} stuck entries)`);
|
||||
} catch (historyErr) {
|
||||
console.warn(`[Recovery] Could not parse attempt_history at ${dir}:`, historyErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop file watcher if it was watching this task
|
||||
|
||||
@@ -0,0 +1,877 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for File Path Self-Healing in the Coder Pipeline
|
||||
=======================================================
|
||||
|
||||
Tests cover:
|
||||
- _find_correct_path: fuzzy file path matching (basename, index.{ext} pattern)
|
||||
- _find_correct_path_indexed: same logic using pre-built index
|
||||
- _build_file_index: file indexing with directory pruning
|
||||
- _auto_correct_subtask_files: end-to-end correction with plan persistence
|
||||
- _validate_plan_file_paths: post-planning validation of all file paths
|
||||
- Phase dependency fix: stuck subtasks unblock downstream phases
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure backend is on path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "apps" / "backend"))
|
||||
|
||||
from agents.coder import (
|
||||
_auto_correct_subtask_files,
|
||||
_build_file_index,
|
||||
_find_correct_path,
|
||||
_find_correct_path_indexed,
|
||||
_validate_plan_file_paths,
|
||||
validate_subtask_files,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FIXTURES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_tree(tmp_path):
|
||||
"""
|
||||
Create a realistic project structure for path matching tests.
|
||||
|
||||
Structure:
|
||||
src/
|
||||
renderer/
|
||||
components/
|
||||
Button.tsx
|
||||
Modal.tsx
|
||||
stores/
|
||||
task-store.ts
|
||||
preload/
|
||||
api/
|
||||
index.ts <- the index.{ext} pattern
|
||||
bridge/
|
||||
index.ts
|
||||
shared/
|
||||
utils/
|
||||
helpers.ts
|
||||
format.ts
|
||||
types/
|
||||
common.ts
|
||||
apps/
|
||||
frontend/
|
||||
src/
|
||||
main/
|
||||
agent/
|
||||
agent-queue.ts
|
||||
tests/
|
||||
helpers.ts <- duplicate basename of shared/utils/helpers.ts
|
||||
node_modules/
|
||||
react/
|
||||
index.ts <- should be excluded
|
||||
.git/
|
||||
config <- should be excluded
|
||||
"""
|
||||
# Source files
|
||||
(tmp_path / "src/renderer/components").mkdir(parents=True)
|
||||
(tmp_path / "src/renderer/components/Button.tsx").write_text("export {}")
|
||||
(tmp_path / "src/renderer/components/Modal.tsx").write_text("export {}")
|
||||
|
||||
(tmp_path / "src/renderer/stores").mkdir(parents=True)
|
||||
(tmp_path / "src/renderer/stores/task-store.ts").write_text("export {}")
|
||||
|
||||
(tmp_path / "src/preload/api").mkdir(parents=True)
|
||||
(tmp_path / "src/preload/api/index.ts").write_text("export {}")
|
||||
|
||||
(tmp_path / "src/preload/bridge").mkdir(parents=True)
|
||||
(tmp_path / "src/preload/bridge/index.ts").write_text("export {}")
|
||||
|
||||
(tmp_path / "src/shared/utils").mkdir(parents=True)
|
||||
(tmp_path / "src/shared/utils/helpers.ts").write_text("export {}")
|
||||
(tmp_path / "src/shared/utils/format.ts").write_text("export {}")
|
||||
|
||||
(tmp_path / "src/shared/types").mkdir(parents=True)
|
||||
(tmp_path / "src/shared/types/common.ts").write_text("export {}")
|
||||
|
||||
(tmp_path / "apps/frontend/src/main/agent").mkdir(parents=True)
|
||||
(tmp_path / "apps/frontend/src/main/agent/agent-queue.ts").write_text("export {}")
|
||||
|
||||
(tmp_path / "tests").mkdir(parents=True)
|
||||
(tmp_path / "tests/helpers.ts").write_text("export {}")
|
||||
|
||||
# Excluded directories (should never match)
|
||||
(tmp_path / "node_modules/react").mkdir(parents=True)
|
||||
(tmp_path / "node_modules/react/index.ts").write_text("export {}")
|
||||
|
||||
(tmp_path / ".git").mkdir(parents=True)
|
||||
(tmp_path / ".git/config").write_text("[core]")
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spec_dir_with_plan(tmp_path):
|
||||
"""Create a spec directory with an implementation plan containing wrong paths."""
|
||||
spec_dir = tmp_path / "spec"
|
||||
spec_dir.mkdir()
|
||||
|
||||
plan = {
|
||||
"feature": "test feature",
|
||||
"phases": [
|
||||
{
|
||||
"id": "phase-1",
|
||||
"name": "Phase 1",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "task-1",
|
||||
"description": "Fix the API",
|
||||
"status": "pending",
|
||||
"files_to_modify": [
|
||||
"src/preload/api.ts", # Wrong: should be src/preload/api/index.ts
|
||||
"src/renderer/components/Button.tsx", # Correct
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "task-2",
|
||||
"description": "Update store",
|
||||
"status": "pending",
|
||||
"files_to_modify": [
|
||||
"src/renderer/stores/task-store.ts", # Correct
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan, indent=2))
|
||||
return spec_dir
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _find_correct_path TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestFindCorrectPath:
|
||||
"""Tests for the _find_correct_path fuzzy file matcher."""
|
||||
|
||||
def test_index_pattern_match(self, project_tree):
|
||||
"""preload/api.ts -> preload/api/index.ts (the core spec-232 scenario)."""
|
||||
result = _find_correct_path("src/preload/api.ts", project_tree)
|
||||
assert result is not None
|
||||
assert Path(result) == Path("src/preload/api/index.ts")
|
||||
|
||||
def test_index_pattern_with_different_dir(self, project_tree):
|
||||
"""preload/bridge.ts -> preload/bridge/index.ts."""
|
||||
result = _find_correct_path("src/preload/bridge.ts", project_tree)
|
||||
assert result is not None
|
||||
assert Path(result) == Path("src/preload/bridge/index.ts")
|
||||
|
||||
def test_basename_match_in_different_dir(self, project_tree):
|
||||
"""When file exists but in wrong directory, finds it by basename."""
|
||||
result = _find_correct_path("src/utils/format.ts", project_tree)
|
||||
assert result is not None
|
||||
assert Path(result) == Path("src/shared/utils/format.ts")
|
||||
|
||||
def test_exact_basename_with_shared_parents_wins(self, project_tree):
|
||||
"""Score prefers candidates sharing more parent directory segments."""
|
||||
result = _find_correct_path("src/renderer/components/Modal.tsx", project_tree)
|
||||
# File exists at the exact path, but _find_correct_path is only called
|
||||
# for missing paths. Let's test a wrong parent instead.
|
||||
result = _find_correct_path("src/components/Modal.tsx", project_tree)
|
||||
assert result is not None
|
||||
assert Path(result) == Path("src/renderer/components/Modal.tsx")
|
||||
|
||||
def test_no_match_for_nonexistent_file(self, project_tree):
|
||||
"""Returns None when no file with matching basename or index pattern exists."""
|
||||
result = _find_correct_path("src/does-not-exist.ts", project_tree)
|
||||
assert result is None
|
||||
|
||||
def test_no_match_without_extension(self, project_tree):
|
||||
"""Returns None for paths without file extension."""
|
||||
result = _find_correct_path("src/preload/api", project_tree)
|
||||
assert result is None
|
||||
|
||||
def test_excluded_dirs_not_matched(self, project_tree):
|
||||
"""Files in node_modules/.git are never returned as matches."""
|
||||
# The only index.ts files are in src/preload/api/ and src/preload/bridge/
|
||||
# and node_modules/react/. A search for "react.ts" should not match
|
||||
# node_modules/react/index.ts because node_modules is excluded.
|
||||
result = _find_correct_path("react.ts", project_tree)
|
||||
assert result is None
|
||||
|
||||
def test_ambiguous_match_returns_none(self, project_tree):
|
||||
"""When two candidates have similar scores, returns None (ambiguous)."""
|
||||
# "helpers.ts" exists at both:
|
||||
# src/shared/utils/helpers.ts
|
||||
# tests/helpers.ts
|
||||
# With parent "foo/" (no shared segments), both score 10.0 with slight
|
||||
# depth differences. The gap should be < 3.0 so it's ambiguous.
|
||||
result = _find_correct_path("foo/helpers.ts", project_tree)
|
||||
assert result is None
|
||||
|
||||
def test_unambiguous_basename_match_with_shared_parents(self, project_tree):
|
||||
"""When one candidate clearly shares more parent path, it wins."""
|
||||
# "helpers.ts" at src/shared/utils/helpers.ts vs tests/helpers.ts
|
||||
# Searching for "src/shared/helpers.ts":
|
||||
# src/shared/utils/helpers.ts: 10.0 + 3.0(src) + 3.0(shared) - 0.5 = 15.5
|
||||
# tests/helpers.ts: 10.0 + 0 - 1.0 = 9.0
|
||||
# Gap = 6.5 >= 3.0, so src/shared/utils/helpers.ts wins
|
||||
result = _find_correct_path("src/shared/helpers.ts", project_tree)
|
||||
assert result is not None
|
||||
assert Path(result) == Path("src/shared/utils/helpers.ts")
|
||||
|
||||
def test_deeply_nested_path_still_matches(self, project_tree):
|
||||
"""Files deep in the tree can be found when path is partially wrong."""
|
||||
result = _find_correct_path(
|
||||
"apps/frontend/src/main/agent-queue.ts", project_tree
|
||||
)
|
||||
assert result is not None
|
||||
assert "agent-queue.ts" in result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _build_file_index + _find_correct_path_indexed TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestBuildFileIndex:
|
||||
"""Tests for the file index builder."""
|
||||
|
||||
def test_indexes_files_by_basename(self, project_tree):
|
||||
index = _build_file_index(project_tree, {".ts"})
|
||||
assert "format.ts" in index
|
||||
assert len(index["format.ts"]) == 1
|
||||
|
||||
def test_indexes_index_files_by_dir_stem(self, project_tree):
|
||||
index = _build_file_index(project_tree, {".ts"})
|
||||
# api/index.ts should be indexed under __dir_stem__:api.ts
|
||||
key = "__dir_stem__:api.ts"
|
||||
assert key in index
|
||||
assert len(index[key]) == 1
|
||||
assert "api/index.ts" in index[key][0][0]
|
||||
|
||||
def test_excludes_node_modules(self, project_tree):
|
||||
index = _build_file_index(project_tree, {".ts"})
|
||||
# node_modules/react/index.ts should NOT appear
|
||||
for entries in index.values():
|
||||
for rel_str, _ in entries:
|
||||
assert "node_modules" not in rel_str
|
||||
|
||||
def test_excludes_git_dir(self, project_tree):
|
||||
index = _build_file_index(project_tree, {".ts", ""})
|
||||
for entries in index.values():
|
||||
for rel_str, _ in entries:
|
||||
assert ".git" not in rel_str
|
||||
|
||||
def test_multiple_suffixes(self, project_tree):
|
||||
index = _build_file_index(project_tree, {".ts", ".tsx"})
|
||||
assert "Button.tsx" in index
|
||||
assert "format.ts" in index
|
||||
|
||||
def test_only_requested_suffixes(self, project_tree):
|
||||
index = _build_file_index(project_tree, {".tsx"})
|
||||
# .ts files should not be in the index
|
||||
assert "format.ts" not in index
|
||||
assert "Button.tsx" in index
|
||||
|
||||
|
||||
class TestFindCorrectPathIndexed:
|
||||
"""Tests for the indexed path finder (same logic, uses pre-built index)."""
|
||||
|
||||
def test_index_pattern_match(self, project_tree):
|
||||
"""Same as _find_correct_path but using indexed version."""
|
||||
index = _build_file_index(project_tree, {".ts"})
|
||||
result = _find_correct_path_indexed(
|
||||
"src/preload/api.ts", ("src", "preload"), index
|
||||
)
|
||||
assert result is not None
|
||||
assert Path(result) == Path("src/preload/api/index.ts")
|
||||
|
||||
def test_basename_match(self, project_tree):
|
||||
index = _build_file_index(project_tree, {".ts"})
|
||||
result = _find_correct_path_indexed(
|
||||
"src/shared/format.ts", ("src", "shared"), index
|
||||
)
|
||||
assert result is not None
|
||||
assert Path(result) == Path("src/shared/utils/format.ts")
|
||||
|
||||
def test_no_match(self, project_tree):
|
||||
index = _build_file_index(project_tree, {".ts"})
|
||||
result = _find_correct_path_indexed(
|
||||
"nonexistent.ts", (), index
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_ambiguous_returns_none(self, project_tree):
|
||||
index = _build_file_index(project_tree, {".ts"})
|
||||
# "helpers.ts" has two matches with no shared parent context
|
||||
result = _find_correct_path_indexed(
|
||||
"foo/helpers.ts", ("foo",), index
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _auto_correct_subtask_files TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestAutoCorrectSubtaskFiles:
|
||||
"""Tests for auto-correcting file paths in a subtask."""
|
||||
|
||||
def test_corrects_index_pattern_and_persists(self, project_tree, tmp_path):
|
||||
"""Corrects api.ts -> api/index.ts and writes to plan file."""
|
||||
spec_dir = tmp_path / "spec"
|
||||
spec_dir.mkdir()
|
||||
|
||||
plan = {
|
||||
"phases": [
|
||||
{
|
||||
"id": "p1",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "t1",
|
||||
"status": "pending",
|
||||
"files_to_modify": [
|
||||
"src/preload/api.ts",
|
||||
"src/renderer/components/Button.tsx",
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
subtask = {
|
||||
"id": "t1",
|
||||
"files_to_modify": [
|
||||
"src/preload/api.ts",
|
||||
"src/renderer/components/Button.tsx",
|
||||
],
|
||||
}
|
||||
|
||||
still_missing = _auto_correct_subtask_files(
|
||||
subtask, ["src/preload/api.ts"], project_tree, spec_dir
|
||||
)
|
||||
|
||||
# No files should remain missing
|
||||
assert still_missing == []
|
||||
|
||||
# In-memory subtask should be updated
|
||||
assert "src/preload/api/index.ts" in subtask["files_to_modify"]
|
||||
assert "src/preload/api.ts" not in subtask["files_to_modify"]
|
||||
|
||||
# Plan file should be persisted with correction
|
||||
saved_plan = json.loads(
|
||||
(spec_dir / "implementation_plan.json").read_text()
|
||||
)
|
||||
saved_files = saved_plan["phases"][0]["subtasks"][0]["files_to_modify"]
|
||||
assert "src/preload/api/index.ts" in saved_files
|
||||
assert "src/preload/api.ts" not in saved_files
|
||||
|
||||
def test_uncorrectable_files_returned(self, project_tree, tmp_path):
|
||||
"""Files with no match are returned as still missing."""
|
||||
spec_dir = tmp_path / "spec"
|
||||
spec_dir.mkdir()
|
||||
plan = {"phases": [{"id": "p1", "subtasks": [{"id": "t1", "status": "pending", "files_to_modify": ["nonexistent.ts"]}]}]}
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
subtask = {"id": "t1", "files_to_modify": ["nonexistent.ts"]}
|
||||
still_missing = _auto_correct_subtask_files(
|
||||
subtask, ["nonexistent.ts"], project_tree, spec_dir
|
||||
)
|
||||
|
||||
assert still_missing == ["nonexistent.ts"]
|
||||
|
||||
def test_no_corrections_skips_write(self, project_tree, tmp_path):
|
||||
"""When nothing can be corrected, plan file is not rewritten."""
|
||||
spec_dir = tmp_path / "spec"
|
||||
spec_dir.mkdir()
|
||||
|
||||
original_content = json.dumps({"phases": [{"id": "p1", "subtasks": [{"id": "t1", "status": "pending", "files_to_modify": ["gone.ts"]}]}]})
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
plan_file.write_text(original_content)
|
||||
mtime_before = plan_file.stat().st_mtime
|
||||
|
||||
subtask = {"id": "t1", "files_to_modify": ["gone.ts"]}
|
||||
_auto_correct_subtask_files(subtask, ["gone.ts"], project_tree, spec_dir)
|
||||
|
||||
# File should not have been rewritten (mtime unchanged)
|
||||
assert plan_file.stat().st_mtime == mtime_before
|
||||
|
||||
def test_corrects_in_memory_without_plan_file(self, project_tree, tmp_path):
|
||||
"""When implementation_plan.json does not exist, corrections still apply in-memory."""
|
||||
spec_dir = tmp_path / "spec"
|
||||
spec_dir.mkdir()
|
||||
# Deliberately do NOT create implementation_plan.json
|
||||
|
||||
subtask = {
|
||||
"id": "t1",
|
||||
"files_to_modify": [
|
||||
"src/preload/api.ts",
|
||||
"src/renderer/components/Button.tsx",
|
||||
],
|
||||
}
|
||||
|
||||
still_missing = _auto_correct_subtask_files(
|
||||
subtask, ["src/preload/api.ts"], project_tree, spec_dir
|
||||
)
|
||||
|
||||
# All correctable files should be resolved
|
||||
assert still_missing == []
|
||||
|
||||
# In-memory subtask should be updated with corrected path
|
||||
assert "src/preload/api/index.ts" in subtask["files_to_modify"]
|
||||
assert "src/preload/api.ts" not in subtask["files_to_modify"]
|
||||
# Uncorrected file should remain unchanged
|
||||
assert "src/renderer/components/Button.tsx" in subtask["files_to_modify"]
|
||||
|
||||
# Plan file should still not exist (no side-effect creation)
|
||||
assert not (spec_dir / "implementation_plan.json").exists()
|
||||
|
||||
def test_corrects_in_memory_with_corrupt_plan_file(self, project_tree, tmp_path):
|
||||
"""When implementation_plan.json contains invalid JSON, corrections still apply in-memory."""
|
||||
spec_dir = tmp_path / "spec"
|
||||
spec_dir.mkdir()
|
||||
|
||||
# Write corrupt plan file
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
plan_file.write_text("not valid json")
|
||||
|
||||
subtask = {
|
||||
"id": "t1",
|
||||
"files_to_modify": [
|
||||
"src/preload/api.ts",
|
||||
"src/renderer/components/Button.tsx",
|
||||
],
|
||||
}
|
||||
|
||||
still_missing = _auto_correct_subtask_files(
|
||||
subtask, ["src/preload/api.ts"], project_tree, spec_dir
|
||||
)
|
||||
|
||||
# All correctable files should be resolved
|
||||
assert still_missing == []
|
||||
|
||||
# In-memory subtask should be updated with corrected path
|
||||
assert "src/preload/api/index.ts" in subtask["files_to_modify"]
|
||||
assert "src/preload/api.ts" not in subtask["files_to_modify"]
|
||||
|
||||
# Corrupt plan file should be left unchanged (not overwritten or deleted)
|
||||
assert plan_file.read_text() == "not valid json"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# validate_subtask_files (with auto-correction integration) TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestValidateSubtaskFilesWithCorrection:
|
||||
"""Tests for validate_subtask_files with auto-correction enabled."""
|
||||
|
||||
def test_passes_when_all_files_exist(self, project_tree):
|
||||
subtask = {
|
||||
"files_to_modify": [
|
||||
"src/renderer/components/Button.tsx",
|
||||
"src/shared/utils/format.ts",
|
||||
]
|
||||
}
|
||||
result = validate_subtask_files(subtask, project_tree)
|
||||
assert result["success"] is True
|
||||
|
||||
def test_fails_without_spec_dir(self, project_tree):
|
||||
"""Without spec_dir, auto-correction is skipped and validation fails."""
|
||||
subtask = {"files_to_modify": ["src/preload/api.ts"]}
|
||||
result = validate_subtask_files(subtask, project_tree)
|
||||
assert result["success"] is False
|
||||
assert "src/preload/api.ts" in result["missing_files"]
|
||||
|
||||
def test_auto_corrects_with_spec_dir(self, project_tree, tmp_path):
|
||||
"""With spec_dir, auto-correction fixes the path and passes."""
|
||||
spec_dir = tmp_path / "spec"
|
||||
spec_dir.mkdir()
|
||||
plan = {
|
||||
"phases": [
|
||||
{
|
||||
"id": "p1",
|
||||
"subtasks": [
|
||||
{"id": "t1", "status": "pending", "files_to_modify": ["src/preload/api.ts"]}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
subtask = {"id": "t1", "files_to_modify": ["src/preload/api.ts"]}
|
||||
result = validate_subtask_files(subtask, project_tree, spec_dir)
|
||||
assert result["success"] is True
|
||||
|
||||
def test_rejects_path_traversal(self, project_tree):
|
||||
"""Paths that resolve outside project are rejected."""
|
||||
subtask = {"files_to_modify": ["../../etc/passwd"]}
|
||||
result = validate_subtask_files(subtask, project_tree)
|
||||
assert result["success"] is False
|
||||
assert len(result["invalid_paths"]) > 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _validate_plan_file_paths TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestValidatePlanFilePaths:
|
||||
"""Tests for post-planning file path validation."""
|
||||
|
||||
def test_all_paths_valid_returns_none(self, project_tree, tmp_path):
|
||||
"""When all paths exist, returns None (no issues)."""
|
||||
spec_dir = tmp_path / "spec"
|
||||
spec_dir.mkdir()
|
||||
plan = {
|
||||
"phases": [
|
||||
{
|
||||
"id": "p1",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "t1",
|
||||
"status": "pending",
|
||||
"files_to_modify": [
|
||||
"src/renderer/components/Button.tsx",
|
||||
"src/shared/utils/format.ts",
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
result = _validate_plan_file_paths(spec_dir, project_tree)
|
||||
assert result is None
|
||||
|
||||
def test_auto_corrects_and_returns_none(self, project_tree, tmp_path):
|
||||
"""Correctable paths are fixed, plan persisted, returns None."""
|
||||
spec_dir = tmp_path / "spec"
|
||||
spec_dir.mkdir()
|
||||
plan = {
|
||||
"phases": [
|
||||
{
|
||||
"id": "p1",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "t1",
|
||||
"status": "pending",
|
||||
"files_to_modify": ["src/preload/api.ts"],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
result = _validate_plan_file_paths(spec_dir, project_tree)
|
||||
assert result is None
|
||||
|
||||
# Verify plan was updated on disk
|
||||
saved = json.loads((spec_dir / "implementation_plan.json").read_text())
|
||||
assert saved["phases"][0]["subtasks"][0]["files_to_modify"] == [
|
||||
"src/preload/api/index.ts"
|
||||
]
|
||||
|
||||
def test_uncorrectable_returns_retry_context(self, project_tree, tmp_path):
|
||||
"""Returns retry context string when paths can't be corrected."""
|
||||
spec_dir = tmp_path / "spec"
|
||||
spec_dir.mkdir()
|
||||
plan = {
|
||||
"phases": [
|
||||
{
|
||||
"id": "p1",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "t1",
|
||||
"status": "pending",
|
||||
"files_to_modify": ["totally/fake/path.ts"],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
result = _validate_plan_file_paths(spec_dir, project_tree)
|
||||
assert result is not None
|
||||
assert "FILE PATH VALIDATION ERRORS" in result
|
||||
assert "totally/fake/path.ts" in result
|
||||
|
||||
def test_mixed_valid_invalid_correctable(self, project_tree, tmp_path):
|
||||
"""Mix of correct, correctable, and uncorrectable paths across subtasks."""
|
||||
spec_dir = tmp_path / "spec"
|
||||
spec_dir.mkdir()
|
||||
plan = {
|
||||
"phases": [
|
||||
{
|
||||
"id": "p1",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "t1",
|
||||
"status": "pending",
|
||||
"files_to_modify": [
|
||||
"src/renderer/components/Button.tsx", # valid
|
||||
"src/preload/api.ts", # correctable
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "t2",
|
||||
"status": "pending",
|
||||
"files_to_modify": [
|
||||
"nonexistent/file.xyz", # uncorrectable
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
result = _validate_plan_file_paths(spec_dir, project_tree)
|
||||
assert result is not None
|
||||
assert "nonexistent/file.xyz" in result
|
||||
# api.ts should have been corrected
|
||||
assert "api.ts" not in result
|
||||
|
||||
def test_no_plan_file_returns_none(self, tmp_path):
|
||||
"""Returns None if implementation_plan.json doesn't exist."""
|
||||
spec_dir = tmp_path / "spec"
|
||||
spec_dir.mkdir()
|
||||
result = _validate_plan_file_paths(spec_dir, tmp_path)
|
||||
assert result is None
|
||||
|
||||
def test_subtask_without_files_to_modify(self, project_tree, tmp_path):
|
||||
"""Subtasks with no files_to_modify are gracefully skipped."""
|
||||
spec_dir = tmp_path / "spec"
|
||||
spec_dir.mkdir()
|
||||
plan = {
|
||||
"phases": [
|
||||
{
|
||||
"id": "p1",
|
||||
"subtasks": [
|
||||
{"id": "t1", "status": "pending", "description": "No files"},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
result = _validate_plan_file_paths(spec_dir, project_tree)
|
||||
assert result is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PHASE DEPENDENCY FIX (progress.py get_next_subtask) TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestStuckSubtasksUnblockPhases:
|
||||
"""Tests that stuck subtasks in a phase allow downstream phases to proceed."""
|
||||
|
||||
def _write_plan(self, spec_dir, plan):
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
|
||||
|
||||
def _write_stuck(self, spec_dir, stuck_ids):
|
||||
memory_dir = spec_dir / "memory"
|
||||
memory_dir.mkdir(parents=True, exist_ok=True)
|
||||
history = {
|
||||
"subtasks": {},
|
||||
"stuck_subtasks": [
|
||||
{"subtask_id": sid, "reason": "test"} for sid in stuck_ids
|
||||
],
|
||||
"metadata": {"created_at": "2025-01-01", "last_updated": "2025-01-01"},
|
||||
}
|
||||
(memory_dir / "attempt_history.json").write_text(json.dumps(history))
|
||||
|
||||
def test_stuck_in_phase1_unblocks_phase2(self, tmp_path):
|
||||
"""When all non-completed subtasks in phase 1 are stuck, phase 2 proceeds."""
|
||||
from progress import get_next_subtask
|
||||
|
||||
spec_dir = tmp_path / "spec"
|
||||
spec_dir.mkdir()
|
||||
|
||||
plan = {
|
||||
"phases": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Phase 1",
|
||||
"subtasks": [
|
||||
{"id": "1.1", "status": "completed"},
|
||||
{"id": "1.2", "status": "pending"}, # This one is stuck
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Phase 2",
|
||||
"depends_on": ["1"],
|
||||
"subtasks": [
|
||||
{"id": "2.1", "status": "pending"},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
self._write_plan(spec_dir, plan)
|
||||
self._write_stuck(spec_dir, ["1.2"])
|
||||
|
||||
result = get_next_subtask(spec_dir)
|
||||
assert result is not None
|
||||
assert result["id"] == "2.1", "Phase 2 should be unblocked since 1.2 is stuck"
|
||||
|
||||
def test_completed_plus_stuck_unblocks(self, tmp_path):
|
||||
"""Phase with mix of completed and stuck subtasks counts as resolved."""
|
||||
from progress import get_next_subtask
|
||||
|
||||
spec_dir = tmp_path / "spec"
|
||||
spec_dir.mkdir()
|
||||
|
||||
plan = {
|
||||
"phases": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Phase 1",
|
||||
"subtasks": [
|
||||
{"id": "1.1", "status": "completed"},
|
||||
{"id": "1.2", "status": "completed"},
|
||||
{"id": "1.3", "status": "pending"}, # stuck
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Phase 2",
|
||||
"depends_on": ["1"],
|
||||
"subtasks": [
|
||||
{"id": "2.1", "status": "pending"},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
self._write_plan(spec_dir, plan)
|
||||
self._write_stuck(spec_dir, ["1.3"])
|
||||
|
||||
result = get_next_subtask(spec_dir)
|
||||
assert result is not None
|
||||
assert result["id"] == "2.1"
|
||||
|
||||
def test_pending_non_stuck_blocks_phase(self, tmp_path):
|
||||
"""Phase with a pending (non-stuck) subtask still blocks dependents."""
|
||||
from progress import get_next_subtask
|
||||
|
||||
spec_dir = tmp_path / "spec"
|
||||
spec_dir.mkdir()
|
||||
|
||||
plan = {
|
||||
"phases": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Phase 1",
|
||||
"subtasks": [
|
||||
{"id": "1.1", "status": "completed"},
|
||||
{"id": "1.2", "status": "pending"}, # stuck
|
||||
{"id": "1.3", "status": "pending"}, # NOT stuck
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Phase 2",
|
||||
"depends_on": ["1"],
|
||||
"subtasks": [
|
||||
{"id": "2.1", "status": "pending"},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
self._write_plan(spec_dir, plan)
|
||||
self._write_stuck(spec_dir, ["1.2"])
|
||||
|
||||
result = get_next_subtask(spec_dir)
|
||||
assert result is not None
|
||||
# Should pick 1.3 (pending, not stuck) from phase 1, NOT 2.1
|
||||
assert result["id"] == "1.3"
|
||||
|
||||
def test_all_phases_stuck_returns_none(self, tmp_path):
|
||||
"""When every pending subtask across all phases is stuck, returns None."""
|
||||
from progress import get_next_subtask
|
||||
|
||||
spec_dir = tmp_path / "spec"
|
||||
spec_dir.mkdir()
|
||||
|
||||
plan = {
|
||||
"phases": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Phase 1",
|
||||
"subtasks": [
|
||||
{"id": "1.1", "status": "pending"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Phase 2",
|
||||
"depends_on": ["1"],
|
||||
"subtasks": [
|
||||
{"id": "2.1", "status": "pending"},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
self._write_plan(spec_dir, plan)
|
||||
self._write_stuck(spec_dir, ["1.1", "2.1"])
|
||||
|
||||
result = get_next_subtask(spec_dir)
|
||||
assert result is None
|
||||
|
||||
def test_chain_of_three_phases_with_stuck(self, tmp_path):
|
||||
"""Phase 1 stuck -> phase 2 stuck -> phase 3 can still run."""
|
||||
from progress import get_next_subtask
|
||||
|
||||
spec_dir = tmp_path / "spec"
|
||||
spec_dir.mkdir()
|
||||
|
||||
plan = {
|
||||
"phases": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Phase 1",
|
||||
"subtasks": [
|
||||
{"id": "1.1", "status": "pending"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Phase 2",
|
||||
"depends_on": ["1"],
|
||||
"subtasks": [
|
||||
{"id": "2.1", "status": "pending"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"name": "Phase 3",
|
||||
"depends_on": ["2"],
|
||||
"subtasks": [
|
||||
{"id": "3.1", "status": "pending"},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
self._write_plan(spec_dir, plan)
|
||||
self._write_stuck(spec_dir, ["1.1", "2.1"])
|
||||
|
||||
result = get_next_subtask(spec_dir)
|
||||
assert result is not None
|
||||
assert result["id"] == "3.1"
|
||||
Reference in New Issue
Block a user