story4.3
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
Story Reference: Story 4.1 - Implement Full Auto Task Executor
|
||||
Story Reference: Story 4.2 - Implement Planning Phase Execution
|
||||
Story Reference: Story 4.3 - Implement Coding Phase Execution
|
||||
|
||||
This module provides the FullAutoExecutor class that executes all methodology
|
||||
phases without user intervention. In Full Auto mode, the system executes
|
||||
@@ -11,6 +12,7 @@ continuously.
|
||||
Architecture Source: architecture.md#Task-Execution
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
@@ -18,7 +20,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
# fcntl is POSIX only - use for file locking on Unix/macOS
|
||||
try:
|
||||
@@ -180,6 +182,17 @@ class FullAutoExecutor:
|
||||
self._phases: list[Phase] = []
|
||||
self._current_state: TaskState | None = None
|
||||
|
||||
# Story 4.3: Initialize spec_dir and worktree_path for coding phase
|
||||
# These can be set from task_config.metadata or explicitly via properties
|
||||
spec_dir_str = (
|
||||
task_config.metadata.get("spec_dir") if task_config.metadata else None
|
||||
)
|
||||
self._spec_dir: Path | None = Path(spec_dir_str) if spec_dir_str else None
|
||||
worktree_str = (
|
||||
task_config.metadata.get("worktree_path") if task_config.metadata else None
|
||||
)
|
||||
self._worktree_path: Path | None = Path(worktree_str) if worktree_str else None
|
||||
|
||||
# Task-specific logger for filtering logs by task ID.
|
||||
# Logger name format: "apps.backend.core.executors.full_auto.{task_id}"
|
||||
# This allows log filtering with: logging.getLogger("...full_auto.task-123")
|
||||
@@ -719,3 +732,419 @@ class FullAutoExecutor:
|
||||
"qa_validation": TaskState.VALIDATION_COMPLETE,
|
||||
}
|
||||
return complete_state_map.get(phase_id)
|
||||
|
||||
# =========================================================================
|
||||
# Story 4.3: Coding Phase Execution Methods
|
||||
# =========================================================================
|
||||
|
||||
def load_implementation_plan(self, spec_dir: Path) -> list[dict]:
|
||||
"""Load subtasks from implementation_plan.json.
|
||||
|
||||
Story Reference: Story 4.3 Task 1 - Load implementation plan
|
||||
|
||||
Args:
|
||||
spec_dir: Directory containing implementation_plan.json
|
||||
|
||||
Returns:
|
||||
List of subtask dictionaries
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If implementation_plan.json doesn't exist
|
||||
json.JSONDecodeError: If the file contains invalid JSON
|
||||
"""
|
||||
plan_path = spec_dir / "implementation_plan.json"
|
||||
if not plan_path.exists():
|
||||
raise FileNotFoundError(f"Implementation plan not found: {plan_path}")
|
||||
|
||||
plan_data = json.loads(plan_path.read_text())
|
||||
return plan_data.get("subtasks", [])
|
||||
|
||||
def get_pending_subtasks(self, spec_dir: Path) -> list[dict]:
|
||||
"""Get subtasks that haven't been completed yet.
|
||||
|
||||
Story Reference: Story 4.3 Task 1 - Determine execution order
|
||||
|
||||
Args:
|
||||
spec_dir: Directory containing implementation_plan.json
|
||||
|
||||
Returns:
|
||||
List of subtasks with status "pending"
|
||||
"""
|
||||
all_subtasks = self.load_implementation_plan(spec_dir)
|
||||
return [s for s in all_subtasks if s.get("status") == "pending"]
|
||||
|
||||
async def execute_subtask(self, subtask: dict) -> dict:
|
||||
"""Execute a single subtask.
|
||||
|
||||
Story Reference: Story 4.3 Task 2 - Implement subtask loop
|
||||
|
||||
This method:
|
||||
1. Marks subtask as in_progress
|
||||
2. Delegates to methodology runner
|
||||
3. Updates subtask status on completion/failure
|
||||
|
||||
Args:
|
||||
subtask: The subtask dictionary to execute
|
||||
|
||||
Returns:
|
||||
Result dictionary with "success" key and optional error
|
||||
"""
|
||||
subtask_id = subtask.get("id", "unknown")
|
||||
self._log_info(f"Executing subtask: {subtask_id}", phase_id="coding")
|
||||
|
||||
# Mark as in_progress in the plan file
|
||||
self._update_subtask_status(subtask_id, "in_progress")
|
||||
|
||||
try:
|
||||
# Execute via methodology runner
|
||||
result = await self._execute_subtask_impl(subtask)
|
||||
|
||||
if result.get("success"):
|
||||
self._update_subtask_status(subtask_id, "completed")
|
||||
else:
|
||||
self._update_subtask_status(subtask_id, "failed")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
self._update_subtask_status(subtask_id, "failed")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def _execute_subtask_impl(
|
||||
self,
|
||||
subtask: dict,
|
||||
attempt: int = 1,
|
||||
recovery_context: str | None = None,
|
||||
) -> dict:
|
||||
"""Internal implementation of subtask execution.
|
||||
|
||||
Story Reference: Story 4.3 Task 2 - Execute via methodology runner
|
||||
|
||||
This method is the core execution logic that can be mocked for testing.
|
||||
|
||||
Args:
|
||||
subtask: The subtask to execute
|
||||
attempt: Current attempt number (1-based)
|
||||
recovery_context: Optional context from previous failed attempts
|
||||
|
||||
Returns:
|
||||
Result dictionary with "success" and optional "error" keys
|
||||
"""
|
||||
# Default implementation - delegates to runner.execute_phase
|
||||
# In real usage, this would invoke the coder agent
|
||||
try:
|
||||
if hasattr(self.runner, "execute_subtask"):
|
||||
result = self.runner.execute_subtask(subtask, attempt, recovery_context)
|
||||
return {"success": result.success, "error": result.error}
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _update_subtask_status(self, subtask_id: str, status: str) -> None:
|
||||
"""Update a subtask's status in the implementation plan.
|
||||
|
||||
Story Reference: Story 4.3 Task 2 - Update status
|
||||
|
||||
Args:
|
||||
subtask_id: ID of the subtask to update
|
||||
status: New status value ("in_progress", "completed", "failed")
|
||||
"""
|
||||
if not self._spec_dir:
|
||||
return
|
||||
|
||||
plan_path = self._spec_dir / "implementation_plan.json"
|
||||
if not plan_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
plan_data = json.loads(plan_path.read_text())
|
||||
subtasks = plan_data.get("subtasks", [])
|
||||
|
||||
for subtask in subtasks:
|
||||
if subtask.get("id") == subtask_id:
|
||||
subtask["status"] = status
|
||||
break
|
||||
|
||||
plan_path.write_text(json.dumps(plan_data, indent=2))
|
||||
except Exception as e:
|
||||
self._log_warning(f"Failed to update subtask status: {e}")
|
||||
|
||||
async def execute_coding_phase(self) -> dict[str, Any]:
|
||||
"""Execute the full coding phase by running all subtasks.
|
||||
|
||||
Story Reference: Story 4.3 Task 2 - Iterate through subtasks
|
||||
|
||||
Returns:
|
||||
Result dictionary with keys:
|
||||
- status: "coding_complete", "failed", "escalated", or "completed"
|
||||
- subtasks_executed: Number of successfully executed subtasks
|
||||
- subtask: ID of failed subtask (if failed/escalated)
|
||||
- error: Error message (if failed/escalated)
|
||||
"""
|
||||
if not self._spec_dir:
|
||||
self._log_warning("No spec_dir set for coding phase")
|
||||
return {"status": "failed", "error": "No spec_dir configured"}
|
||||
|
||||
try:
|
||||
pending_subtasks = self.get_pending_subtasks(self._spec_dir)
|
||||
except FileNotFoundError:
|
||||
self._log_warning("No implementation plan found")
|
||||
return {"status": "completed", "subtasks_executed": 0}
|
||||
|
||||
total_subtasks = len(pending_subtasks)
|
||||
if total_subtasks == 0:
|
||||
self._log_info("No pending subtasks - coding phase complete")
|
||||
self.update_task_state(TaskState.CODING_COMPLETE)
|
||||
return {"status": "coding_complete", "subtasks_executed": 0}
|
||||
|
||||
self._log_info(f"Starting coding phase with {total_subtasks} subtasks")
|
||||
self.update_task_state(TaskState.CODING)
|
||||
|
||||
executed = 0
|
||||
for index, subtask in enumerate(pending_subtasks):
|
||||
subtask_id = subtask.get("id", "unknown")
|
||||
|
||||
# Emit progress on start
|
||||
self.emit_subtask_progress(index, total_subtasks, subtask, "started")
|
||||
|
||||
# Execute with retry logic
|
||||
result = await self.execute_subtask_with_retry(subtask)
|
||||
|
||||
if result.get("success"):
|
||||
executed += 1
|
||||
# Update subtask status to completed in plan file
|
||||
self._update_subtask_status(subtask_id, "completed")
|
||||
# Emit progress on complete
|
||||
self.emit_subtask_progress(index, total_subtasks, subtask, "completed")
|
||||
# Commit changes after successful subtask
|
||||
self.commit_subtask_changes(self.format_commit_message(subtask))
|
||||
elif result.get("escalated"):
|
||||
# Escalation - preserve partial progress before stopping
|
||||
self._update_subtask_status(subtask_id, "failed")
|
||||
self.emit_subtask_progress(index, total_subtasks, subtask, "failed")
|
||||
self.update_task_state(TaskState.ESCALATED)
|
||||
self._log_error(
|
||||
f"Subtask {subtask_id} escalated after max retries",
|
||||
phase_id="coding",
|
||||
)
|
||||
return {
|
||||
"status": "escalated",
|
||||
"subtask": subtask_id,
|
||||
"subtasks_executed": executed,
|
||||
"error": result.get("error"),
|
||||
}
|
||||
else:
|
||||
# Non-escalated failure (unexpected) - stop execution and mark failed
|
||||
self._update_subtask_status(subtask_id, "failed")
|
||||
self.emit_subtask_progress(index, total_subtasks, subtask, "failed")
|
||||
self.update_task_state(TaskState.FAILED)
|
||||
error_msg = result.get("error", "Unknown failure")
|
||||
self._log_error(
|
||||
f"Subtask {subtask_id} failed: {error_msg}",
|
||||
phase_id="coding",
|
||||
)
|
||||
return {
|
||||
"status": "failed",
|
||||
"subtask": subtask_id,
|
||||
"subtasks_executed": executed,
|
||||
"error": error_msg,
|
||||
}
|
||||
|
||||
self._log_info(f"Coding phase complete: {executed}/{total_subtasks} subtasks")
|
||||
self.update_task_state(TaskState.CODING_COMPLETE)
|
||||
return {"status": "coding_complete", "subtasks_executed": executed}
|
||||
|
||||
def emit_subtask_progress(
|
||||
self,
|
||||
index: int,
|
||||
total: int,
|
||||
subtask: dict,
|
||||
status: str,
|
||||
) -> None:
|
||||
"""Emit progress event for a subtask.
|
||||
|
||||
Story Reference: Story 4.3 Task 3 - Per-subtask progress reporting
|
||||
|
||||
Args:
|
||||
index: Current subtask index (0-based)
|
||||
total: Total number of subtasks
|
||||
subtask: The subtask dictionary
|
||||
status: Status string ("started", "completed", "failed")
|
||||
"""
|
||||
percentage = self.calculate_subtask_percentage(index, total)
|
||||
subtask_id = subtask.get("id", "unknown")
|
||||
subtask_title = subtask.get("title", subtask.get("description", "subtask"))
|
||||
|
||||
message = f"Subtask {status}: {subtask_title}"
|
||||
|
||||
progress_status = (
|
||||
ProgressStatus.STARTED
|
||||
if status == "started"
|
||||
else (
|
||||
ProgressStatus.COMPLETED
|
||||
if status == "completed"
|
||||
else ProgressStatus.FAILED
|
||||
)
|
||||
)
|
||||
|
||||
self._emit_progress_event(
|
||||
phase_id="coding",
|
||||
status=progress_status,
|
||||
message=message,
|
||||
percentage=percentage,
|
||||
)
|
||||
|
||||
def calculate_subtask_percentage(self, completed: int, total: int) -> float:
|
||||
"""Calculate progress percentage for subtask execution.
|
||||
|
||||
Story Reference: Story 4.3 Task 3 - Calculate percentage
|
||||
|
||||
The coding phase spans 35-75% of overall progress (40% range).
|
||||
This method calculates where within that range we are.
|
||||
|
||||
Args:
|
||||
completed: Number of completed subtasks
|
||||
total: Total number of subtasks
|
||||
|
||||
Returns:
|
||||
Percentage as float (35.0 to 75.0)
|
||||
"""
|
||||
if total == 0:
|
||||
return 35.0 # Start of coding phase
|
||||
|
||||
# Coding phase is 35-75% (40% range)
|
||||
base_percentage = 35.0
|
||||
range_percentage = 40.0
|
||||
return base_percentage + (completed / total) * range_percentage
|
||||
|
||||
def commit_subtask_changes(self, message: str) -> bool:
|
||||
"""Commit changes after subtask completion.
|
||||
|
||||
Story Reference: Story 4.3 Task 4 - Git commit integration
|
||||
|
||||
Args:
|
||||
message: Commit message
|
||||
|
||||
Returns:
|
||||
True if commit succeeded, False otherwise
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
if not self._worktree_path:
|
||||
self._log_warning("No worktree path configured for commits")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Stage all changes including deletions (git add -A as per Dev Notes)
|
||||
result = subprocess.run(
|
||||
["git", "add", "-A"],
|
||||
cwd=self._worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
self._log_warning(f"Git add failed: {result.stderr}")
|
||||
return False
|
||||
|
||||
# Commit
|
||||
result = subprocess.run(
|
||||
["git", "commit", "-m", message],
|
||||
cwd=self._worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
if (
|
||||
"nothing to commit" in result.stdout
|
||||
or "nothing to commit" in result.stderr
|
||||
):
|
||||
return True # No changes to commit
|
||||
self._log_warning(f"Git commit failed: {result.stderr}")
|
||||
return False
|
||||
|
||||
self._log_info(f"Committed changes: {message}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self._log_warning(f"Git operation failed: {e}")
|
||||
return False
|
||||
|
||||
def format_commit_message(self, subtask: dict) -> str:
|
||||
"""Format a commit message for a subtask.
|
||||
|
||||
Story Reference: Story 4.3 Task 4 - Meaningful commit messages
|
||||
|
||||
Args:
|
||||
subtask: The completed subtask
|
||||
|
||||
Returns:
|
||||
Formatted commit message string
|
||||
"""
|
||||
subtask_id = subtask.get("id", "unknown")
|
||||
title = subtask.get("title", subtask.get("description", "subtask"))
|
||||
return f"Implement: {title} (#{subtask_id})"
|
||||
|
||||
async def execute_subtask_with_retry(
|
||||
self,
|
||||
subtask: dict,
|
||||
max_retries: int = 3,
|
||||
) -> dict:
|
||||
"""Execute a subtask with retry logic.
|
||||
|
||||
Story Reference: Story 4.3 Task 5 - Retry logic
|
||||
|
||||
Implements exponential backoff with recovery context passed
|
||||
on retry attempts.
|
||||
|
||||
Args:
|
||||
subtask: The subtask to execute
|
||||
max_retries: Maximum number of attempts (default 3)
|
||||
|
||||
Returns:
|
||||
Result dictionary with "success", "error", and potentially "escalated"
|
||||
"""
|
||||
|
||||
last_error = None
|
||||
recovery_context = None
|
||||
|
||||
for attempt in range(1, max_retries + 1):
|
||||
self._log_info(
|
||||
f"Executing subtask {subtask.get('id')} (attempt {attempt}/{max_retries})",
|
||||
phase_id="coding",
|
||||
)
|
||||
|
||||
result = await self._execute_subtask_impl(
|
||||
subtask,
|
||||
attempt=attempt,
|
||||
recovery_context=recovery_context,
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
return result
|
||||
|
||||
last_error = result.get("error", "Unknown error")
|
||||
recovery_context = f"Previous attempt {attempt} failed: {last_error}"
|
||||
|
||||
# Exponential backoff (1s, 2s, 4s)
|
||||
if attempt < max_retries:
|
||||
backoff = 2 ** (attempt - 1)
|
||||
self._log_info(
|
||||
f"Retry in {backoff}s due to: {last_error}",
|
||||
phase_id="coding",
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
|
||||
# All retries exhausted - escalate
|
||||
self._log_error(
|
||||
f"Subtask {subtask.get('id')} failed after {max_retries} attempts: {last_error}",
|
||||
phase_id="coding",
|
||||
)
|
||||
self.update_task_state(TaskState.ESCALATED)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Escalated after {max_retries} failed attempts: {last_error}",
|
||||
"escalated": True,
|
||||
}
|
||||
|
||||
@@ -1019,3 +1019,507 @@ class TestArtifactVerificationIntegration:
|
||||
|
||||
# Phase executed but verification should be part of the flow
|
||||
assert "planning" in runner._executed_phases
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Story 4.3: Coding Phase Execution Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCodingPhaseSubtaskLoading:
|
||||
"""Test loading implementation plan for coding phase (Story 4.3 Task 1)."""
|
||||
|
||||
def test_load_implementation_plan(self, executor):
|
||||
"""Test loading subtasks from implementation_plan.json."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
spec_dir = Path(tmpdir)
|
||||
plan_data = {
|
||||
"spec_id": "test-spec",
|
||||
"subtasks": [
|
||||
{"id": "1", "title": "First subtask", "status": "pending"},
|
||||
{"id": "2", "title": "Second subtask", "status": "pending"},
|
||||
{"id": "3", "title": "Third subtask", "status": "completed"},
|
||||
],
|
||||
}
|
||||
import json
|
||||
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan_data))
|
||||
|
||||
subtasks = executor.load_implementation_plan(spec_dir)
|
||||
|
||||
assert len(subtasks) == 3
|
||||
assert subtasks[0]["id"] == "1"
|
||||
assert subtasks[1]["title"] == "Second subtask"
|
||||
|
||||
def test_load_implementation_plan_missing_file(self, executor):
|
||||
"""Test that missing plan file raises appropriate error."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
spec_dir = Path(tmpdir)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
executor.load_implementation_plan(spec_dir)
|
||||
|
||||
def test_load_implementation_plan_invalid_json(self, executor):
|
||||
"""Test that invalid JSON raises appropriate error."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
spec_dir = Path(tmpdir)
|
||||
(spec_dir / "implementation_plan.json").write_text("invalid json")
|
||||
|
||||
with pytest.raises(Exception): # Could be json.JSONDecodeError
|
||||
executor.load_implementation_plan(spec_dir)
|
||||
|
||||
def test_get_pending_subtasks(self, executor):
|
||||
"""Test filtering to get only pending subtasks."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
spec_dir = Path(tmpdir)
|
||||
plan_data = {
|
||||
"subtasks": [
|
||||
{"id": "1", "title": "First", "status": "completed"},
|
||||
{"id": "2", "title": "Second", "status": "pending"},
|
||||
{"id": "3", "title": "Third", "status": "in_progress"},
|
||||
{"id": "4", "title": "Fourth", "status": "pending"},
|
||||
],
|
||||
}
|
||||
import json
|
||||
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan_data))
|
||||
|
||||
pending = executor.get_pending_subtasks(spec_dir)
|
||||
|
||||
assert len(pending) == 2
|
||||
assert pending[0]["id"] == "2"
|
||||
assert pending[1]["id"] == "4"
|
||||
|
||||
|
||||
class TestCodingPhaseSubtaskExecution:
|
||||
"""Test subtask execution loop (Story 4.3 Task 2)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_subtask_updates_status(self, executor):
|
||||
"""Test that executing a subtask updates its status in the plan."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
spec_dir = Path(tmpdir)
|
||||
plan_data = {
|
||||
"subtasks": [
|
||||
{"id": "1", "title": "Test subtask", "status": "pending"},
|
||||
],
|
||||
}
|
||||
import json
|
||||
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
plan_file.write_text(json.dumps(plan_data))
|
||||
executor._spec_dir = spec_dir
|
||||
|
||||
# Execute the subtask
|
||||
subtask = plan_data["subtasks"][0]
|
||||
result = await executor.execute_subtask(subtask)
|
||||
|
||||
# Reload plan and check status updated
|
||||
updated_plan = json.loads(plan_file.read_text())
|
||||
# Status should be either "completed" (success) or "failed"
|
||||
assert updated_plan["subtasks"][0]["status"] in ["completed", "in_progress", "failed"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_all_subtasks_in_sequence(self, executor):
|
||||
"""Test that all subtasks are executed in sequence."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
spec_dir = Path(tmpdir)
|
||||
plan_data = {
|
||||
"subtasks": [
|
||||
{"id": "1", "title": "First", "status": "pending"},
|
||||
{"id": "2", "title": "Second", "status": "pending"},
|
||||
{"id": "3", "title": "Third", "status": "pending"},
|
||||
],
|
||||
}
|
||||
import json
|
||||
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
plan_file.write_text(json.dumps(plan_data))
|
||||
executor._spec_dir = spec_dir
|
||||
executor._task_dir = Path(tmpdir)
|
||||
|
||||
# Track execution order
|
||||
executed_ids = []
|
||||
|
||||
async def mock_execute_impl(subtask, attempt=1, recovery_context=None):
|
||||
executed_ids.append(subtask["id"])
|
||||
return {"success": True}
|
||||
|
||||
# Patch the implementation method
|
||||
with patch.object(executor, "_execute_subtask_impl", side_effect=mock_execute_impl):
|
||||
await executor.execute_coding_phase()
|
||||
|
||||
# All subtasks should be executed in order
|
||||
assert executed_ids == ["1", "2", "3"]
|
||||
|
||||
|
||||
class TestCodingPhaseProgressReporting:
|
||||
"""Test per-subtask progress reporting (Story 4.3 Task 3)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emit_progress_on_subtask_start(self, executor, mock_progress_service):
|
||||
"""Test that progress is emitted when subtask starts."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
spec_dir = Path(tmpdir)
|
||||
plan_data = {
|
||||
"subtasks": [
|
||||
{"id": "1", "title": "Test subtask", "status": "pending"},
|
||||
],
|
||||
}
|
||||
import json
|
||||
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan_data))
|
||||
executor._spec_dir = spec_dir
|
||||
executor.context.progress = mock_progress_service
|
||||
|
||||
executor.emit_subtask_progress(0, 1, plan_data["subtasks"][0], "started")
|
||||
|
||||
# Should have emitted a started event
|
||||
assert len(mock_progress_service.events) >= 1
|
||||
last_event = mock_progress_service.events[-1]
|
||||
assert "started" in last_event.message.lower() or last_event.status == "started"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emit_progress_on_subtask_complete(self, executor, mock_progress_service):
|
||||
"""Test that progress is emitted when subtask completes."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
spec_dir = Path(tmpdir)
|
||||
plan_data = {
|
||||
"subtasks": [
|
||||
{"id": "1", "title": "Test subtask", "status": "pending"},
|
||||
],
|
||||
}
|
||||
import json
|
||||
|
||||
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan_data))
|
||||
executor._spec_dir = spec_dir
|
||||
executor.context.progress = mock_progress_service
|
||||
|
||||
executor.emit_subtask_progress(0, 1, plan_data["subtasks"][0], "completed")
|
||||
|
||||
# Should have emitted a completed event
|
||||
assert len(mock_progress_service.events) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_progress_percentage_calculation(self, executor, mock_progress_service):
|
||||
"""Test that percentage is calculated based on subtask count."""
|
||||
# Coding phase is 35-75% of overall progress (40% range)
|
||||
# 1 of 3 subtasks = 35 + (1/3)*40 = 35 + 13.33 = 48.33%
|
||||
|
||||
percentage = executor.calculate_subtask_percentage(1, 3)
|
||||
|
||||
# Should be around 48%
|
||||
assert 45 <= percentage <= 55
|
||||
|
||||
|
||||
class TestCodingPhaseGitCommit:
|
||||
"""Test git commit integration (Story 4.3 Task 4)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_after_subtask(self, executor):
|
||||
"""Test that changes are committed using git add -A to include deletions."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
executor._worktree_path = Path(tmpdir)
|
||||
|
||||
# Mock subprocess.run
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value.returncode = 0
|
||||
|
||||
executor.commit_subtask_changes("Implement: Test subtask")
|
||||
|
||||
# Verify git add -A is used (not git add .)
|
||||
add_call = mock_run.call_args_list[0]
|
||||
assert add_call[0][0] == ["git", "add", "-A"], (
|
||||
f"Expected 'git add -A', got {add_call[0][0]}"
|
||||
)
|
||||
|
||||
# Verify commit call uses the message
|
||||
commit_call = mock_run.call_args_list[1]
|
||||
assert commit_call[0][0][:3] == ["git", "commit", "-m"]
|
||||
assert "Implement: Test subtask" in commit_call[0][0][3]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_uses_worktree_path(self, executor):
|
||||
"""Test that commits are made to the correct worktree directory."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
worktree_path = Path(tmpdir) / "worktree"
|
||||
worktree_path.mkdir()
|
||||
executor._worktree_path = worktree_path
|
||||
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value.returncode = 0
|
||||
|
||||
executor.commit_subtask_changes("Test message")
|
||||
|
||||
# Verify cwd parameter is the worktree path
|
||||
for call in mock_run.call_args_list:
|
||||
assert call[1].get("cwd") == worktree_path, (
|
||||
f"Expected cwd={worktree_path}, got {call[1].get('cwd')}"
|
||||
)
|
||||
|
||||
def test_commit_message_format(self, executor):
|
||||
"""Test that commit messages follow expected format."""
|
||||
subtask = {"id": "1", "title": "Add user authentication"}
|
||||
|
||||
message = executor.format_commit_message(subtask)
|
||||
|
||||
assert "Implement:" in message or "Add user authentication" in message
|
||||
|
||||
|
||||
class TestCodingPhaseRetryLogic:
|
||||
"""Test retry logic for failed subtasks (Story 4.3 Task 5)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_on_failure(self, executor):
|
||||
"""Test that failed subtask is retried up to 3 times."""
|
||||
attempts = []
|
||||
|
||||
async def failing_execute(subtask, attempt=1, recovery_context=None):
|
||||
attempts.append(attempt)
|
||||
if len(attempts) < 3:
|
||||
return {"success": False, "error": "Simulated failure"}
|
||||
return {"success": True}
|
||||
|
||||
with patch.object(executor, "_execute_subtask_impl", side_effect=failing_execute):
|
||||
subtask = {"id": "1", "title": "Test", "status": "pending"}
|
||||
result = await executor.execute_subtask_with_retry(subtask)
|
||||
|
||||
assert len(attempts) == 3
|
||||
assert result["success"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exponential_backoff(self, executor):
|
||||
"""Test that retries use exponential backoff with exact delay values."""
|
||||
async def always_fail(subtask, attempt=1, recovery_context=None):
|
||||
return {"success": False, "error": "Always fails"}
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
executor._task_dir = Path(tmpdir)
|
||||
with patch.object(executor, "_execute_subtask_impl", side_effect=always_fail):
|
||||
with patch("apps.backend.core.executors.full_auto.asyncio.sleep") as mock_sleep:
|
||||
subtask = {"id": "1", "title": "Test", "status": "pending"}
|
||||
await executor.execute_subtask_with_retry(subtask, max_retries=3)
|
||||
|
||||
# Should have called sleep with exponential delays: 1, 2 (between 3 attempts)
|
||||
# Formula: 2 ** (attempt - 1) => 2^0=1, 2^1=2
|
||||
assert mock_sleep.call_count == 2, f"Expected 2 sleep calls, got {mock_sleep.call_count}"
|
||||
sleep_calls = [call[0][0] for call in mock_sleep.call_args_list]
|
||||
assert sleep_calls[0] == 1, f"First backoff should be 1s, got {sleep_calls[0]}"
|
||||
assert sleep_calls[1] == 2, f"Second backoff should be 2s, got {sleep_calls[1]}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recovery_mode_prompt_on_retry(self, executor):
|
||||
"""Test that retry uses recovery mode prompt with error context from previous attempt."""
|
||||
retry_contexts = []
|
||||
error_message = "Simulated error for testing"
|
||||
|
||||
async def capture_execute(subtask, attempt=1, recovery_context=None):
|
||||
retry_contexts.append(recovery_context)
|
||||
if attempt < 2:
|
||||
return {"success": False, "error": error_message}
|
||||
return {"success": True}
|
||||
|
||||
with patch.object(executor, "_execute_subtask_impl", side_effect=capture_execute):
|
||||
subtask = {"id": "1", "title": "Test", "status": "pending"}
|
||||
await executor.execute_subtask_with_retry(subtask)
|
||||
|
||||
# Verify recovery context structure
|
||||
assert len(retry_contexts) == 2, f"Expected 2 attempts, got {len(retry_contexts)}"
|
||||
assert retry_contexts[0] is None, "First attempt should have no recovery context"
|
||||
assert retry_contexts[1] is not None, "Second attempt must have recovery context"
|
||||
assert error_message in retry_contexts[1], (
|
||||
f"Recovery context should contain previous error. Got: {retry_contexts[1]}"
|
||||
)
|
||||
|
||||
|
||||
class TestCodingPhaseEscalation:
|
||||
"""Test escalation handling for persistent failures (Story 4.3 Task 6)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escalate_after_max_retries(self, executor):
|
||||
"""Test that task is escalated after 3 failed retries."""
|
||||
|
||||
async def always_fail(subtask, attempt=1, recovery_context=None):
|
||||
return {"success": False, "error": "Always fails"}
|
||||
|
||||
with patch.object(executor, "_execute_subtask_impl", side_effect=always_fail):
|
||||
subtask = {"id": "1", "title": "Test", "status": "pending"}
|
||||
result = await executor.execute_subtask_with_retry(subtask, max_retries=3)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result.get("escalated") is True or "escalated" in str(result.get("error", "")).lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escalation_updates_task_state(self, executor):
|
||||
"""Test that escalation updates task state to 'escalated'."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
executor._task_dir = Path(tmpdir)
|
||||
|
||||
async def always_fail(subtask, attempt=1, recovery_context=None):
|
||||
return {"success": False, "error": "Always fails"}
|
||||
|
||||
with patch.object(executor, "_execute_subtask_impl", side_effect=always_fail):
|
||||
subtask = {"id": "1", "title": "Test", "status": "pending"}
|
||||
await executor.execute_subtask_with_retry(subtask, max_retries=3)
|
||||
|
||||
from apps.backend.core.executors.full_auto import TaskState
|
||||
|
||||
state = executor.get_task_state()
|
||||
assert state in [TaskState.ESCALATED.value, TaskState.FAILED.value, "escalated", "failed"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_progress_preserved_on_escalation(self, executor):
|
||||
"""Test that partial progress is preserved when task is escalated."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
spec_dir = Path(tmpdir)
|
||||
plan_data = {
|
||||
"subtasks": [
|
||||
{"id": "1", "title": "First", "status": "pending"},
|
||||
{"id": "2", "title": "Second", "status": "pending"},
|
||||
],
|
||||
}
|
||||
import json
|
||||
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
plan_file.write_text(json.dumps(plan_data))
|
||||
executor._spec_dir = spec_dir
|
||||
executor._task_dir = Path(tmpdir)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def first_success_then_escalate(subtask, attempt=1, recovery_context=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if subtask.get("id") == "1":
|
||||
return {"success": True}
|
||||
# Second subtask fails and escalates
|
||||
return {"success": False, "error": "Always fails", "escalated": True}
|
||||
|
||||
with patch.object(executor, "_execute_subtask_impl", side_effect=first_success_then_escalate):
|
||||
result = await executor.execute_coding_phase()
|
||||
|
||||
# Verify escalation occurred
|
||||
assert result["status"] == "escalated"
|
||||
assert result["subtask"] == "2"
|
||||
assert result["subtasks_executed"] == 1
|
||||
|
||||
# Verify first subtask is still completed (preserved)
|
||||
updated_plan = json.loads(plan_file.read_text())
|
||||
assert updated_plan["subtasks"][0]["status"] == "completed"
|
||||
# Second subtask should be marked as failed
|
||||
assert updated_plan["subtasks"][1]["status"] == "failed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_escalated_failure_stops_execution(self, executor):
|
||||
"""Test that a non-escalated failure stops execution and marks task failed.
|
||||
|
||||
This tests the edge case where execute_subtask_with_retry returns
|
||||
{"success": False} without the "escalated" key.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
spec_dir = Path(tmpdir)
|
||||
plan_data = {
|
||||
"subtasks": [
|
||||
{"id": "1", "title": "First", "status": "pending"},
|
||||
{"id": "2", "title": "Second", "status": "pending"},
|
||||
],
|
||||
}
|
||||
import json
|
||||
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
plan_file.write_text(json.dumps(plan_data))
|
||||
executor._spec_dir = spec_dir
|
||||
executor._task_dir = Path(tmpdir)
|
||||
|
||||
# Simulate a failure that doesn't trigger escalation
|
||||
async def non_escalated_fail(subtask, attempt=1, recovery_context=None):
|
||||
return {"success": False, "error": "Network timeout"}
|
||||
|
||||
with patch.object(executor, "_execute_subtask_impl", side_effect=non_escalated_fail):
|
||||
# Also patch execute_subtask_with_retry to return non-escalated failure
|
||||
with patch.object(
|
||||
executor,
|
||||
"execute_subtask_with_retry",
|
||||
return_value={"success": False, "error": "Non-escalated failure"},
|
||||
):
|
||||
result = await executor.execute_coding_phase()
|
||||
|
||||
# Should fail, not silently continue
|
||||
assert result["status"] == "failed"
|
||||
assert "error" in result
|
||||
from apps.backend.core.executors.full_auto import TaskState
|
||||
|
||||
state = executor.get_task_state()
|
||||
assert state == TaskState.FAILED.value
|
||||
|
||||
|
||||
class TestCodingPhaseCompletion:
|
||||
"""Test state update on completion (Story 4.3 Task 7)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_coding_complete_state_on_success(self, executor):
|
||||
"""Test that successful coding phase updates state to coding_complete."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
executor._task_dir = Path(tmpdir)
|
||||
executor._spec_dir = Path(tmpdir)
|
||||
|
||||
plan_data = {
|
||||
"subtasks": [
|
||||
{"id": "1", "title": "Only subtask", "status": "pending"},
|
||||
],
|
||||
}
|
||||
import json
|
||||
|
||||
(executor._spec_dir / "implementation_plan.json").write_text(json.dumps(plan_data))
|
||||
|
||||
async def mock_execute(subtask, attempt=1, recovery_context=None):
|
||||
return {"success": True}
|
||||
|
||||
with patch.object(executor, "_execute_subtask_impl", side_effect=mock_execute):
|
||||
result = await executor.execute_coding_phase()
|
||||
|
||||
from apps.backend.core.executors.full_auto import TaskState
|
||||
|
||||
state = executor.get_task_state()
|
||||
assert state == TaskState.CODING_COMPLETE.value or result.get("status") == "coding_complete"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validation_triggered_after_coding_complete(self, mock_context):
|
||||
"""Test that validation phase is triggered after coding completes."""
|
||||
phases = [
|
||||
Phase(id="planning", name="Planning", order=0),
|
||||
Phase(id="coding", name="Coding", order=1),
|
||||
Phase(id="validation", name="Validation", order=2),
|
||||
]
|
||||
runner = MockMethodologyRunner(phases=phases)
|
||||
executor = FullAutoExecutor(
|
||||
runner=runner,
|
||||
context=mock_context,
|
||||
task_config=mock_context.task_config,
|
||||
)
|
||||
|
||||
await executor.execute()
|
||||
|
||||
# After coding, validation should automatically run
|
||||
assert "coding" in runner._executed_phases
|
||||
assert "validation" in runner._executed_phases
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completion_logged(self, executor):
|
||||
"""Test that coding phase completion is logged."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
executor._task_dir = Path(tmpdir)
|
||||
executor._log_dir = Path(tmpdir)
|
||||
executor._spec_dir = Path(tmpdir)
|
||||
|
||||
plan_data = {"subtasks": []}
|
||||
import json
|
||||
|
||||
(executor._spec_dir / "implementation_plan.json").write_text(json.dumps(plan_data))
|
||||
|
||||
with patch.object(executor._logger, "info") as mock_info:
|
||||
await executor.execute_coding_phase()
|
||||
|
||||
# Should have logged completion
|
||||
calls_str = str(mock_info.call_args_list)
|
||||
assert "complete" in calls_str.lower() or "finished" in calls_str.lower() or len(mock_info.call_args_list) > 0
|
||||
|
||||
Reference in New Issue
Block a user