Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ docker run -p 5200:5200 -v ./config.json:/app/config.json orchestration
- **Traceability**: requirements numbered [R1], [R2], mapped to tasks; coverage endpoint shows gaps
- **External execution**: MCP server (`backend/mcp/server.py`) for Claude Code integration. Execution modes: auto (engine-only), hybrid (Ollama internal, Claude external), external (all external). Tasks claimed atomically via CAS, results submitted with cost tracking.
- **Git integration**: optional per-project (`repo_path` nullable). `GitService` wraps subprocess via `asyncio.to_thread()`. Config in `git.*` section. Phase 1 (foundation) complete; execution wiring (Phase 2+) pending.
- **Tests**: Backend: pytest-asyncio (auto mode), 731 tests. Frontend: vitest + @testing-library/react, 137 tests. Load tests: 7 (excluded from CI via `slow` marker)
- **Tests**: Backend: pytest-asyncio (auto mode), 785 tests. Frontend: vitest + @testing-library/react, 137 tests. Load tests: 7 (excluded from CI via `slow` marker)

## Git Workflow

Expand Down
1 change: 1 addition & 0 deletions backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ def cfg(path: str, default=None):
KNOWLEDGE_INJECTION_MAX_CHARS = cfg("execution.knowledge_injection_max_chars", 3000)
KNOWLEDGE_MIN_OUTPUT_LENGTH = cfg("execution.knowledge_min_output_length", 200)
EXTERNAL_CLAIM_TIMEOUT_SECONDS = cfg("execution.external_claim_timeout_seconds", 3600)
CSHARP_BUILD_VERIFY_ENABLED = cfg("execution.csharp_build_verify", True)

# Model pricing
MODEL_PRICING = cfg("model_pricing", {})
Expand Down
13 changes: 13 additions & 0 deletions backend/services/decomposer.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ async def decompose(self, project_id: str, plan_id: str) -> dict:
_create_csharp_assembly_tasks(
tasks_data, task_ids, waves, phase_names,
project_id, plan_id, now, write_statements,
project_row=project_row,
)

# Mark plan as approved
Expand Down Expand Up @@ -236,6 +237,7 @@ async def decompose_plan(project_id: str, plan_id: str, *, db) -> dict:
def _create_csharp_assembly_tasks(
tasks_data, task_ids, waves, phase_names,
project_id, plan_id, now, write_statements,
*, project_row=None,
):
"""Auto-create assembly tasks for C# method phases.

Expand All @@ -254,6 +256,15 @@ def _create_csharp_assembly_tasks(
if not class_tasks:
return

# Extract csproj_path from project config for build verification
csproj_path = None
if project_row:
try:
config = json.loads(project_row["config_json"] or "{}")
csproj_path = config.get("csproj_path")
except (json.JSONDecodeError, TypeError, KeyError):
pass

for target_class, method_indices in class_tasks.items():
assembly_id = uuid.uuid4().hex[:12]
class_name = target_class.split(".")[-1]
Expand All @@ -277,6 +288,8 @@ def _create_csharp_assembly_tasks(
{"type": "task_description", "content": description},
{"type": "target_class", "content": target_class},
]
if csproj_path:
context.append({"type": "csproj_path", "content": csproj_path})
if affected:
context.append({"type": "affected_files", "content": ", ".join(sorted(affected))})

Expand Down
91 changes: 91 additions & 0 deletions backend/services/task_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from backend.config import (
CHECKPOINT_ON_RETRY_EXHAUSTED,
CONTEXT_FORWARD_MAX_CHARS,
CSHARP_BUILD_VERIFY_ENABLED,
DIAGNOSTIC_RAG_ENABLED,
KNOWLEDGE_EXTRACTION_ENABLED,
VERIFICATION_ENABLED,
Expand Down Expand Up @@ -390,6 +391,12 @@ async def execute_task(
),
)

# C# build verification for assembly tasks
if await _run_csharp_build_verification(
task_row, task_id, db, progress, project_id,
):
return # Task reset to PENDING with build error feedback

# Optional output verification (skip for Ollama — free tasks)
# Run BEFORE forwarding context to prevent dependents from
# receiving output that verification may reject.
Expand Down Expand Up @@ -569,6 +576,12 @@ async def complete_task_external(
),
)

# C# build verification for assembly tasks
if await _run_csharp_build_verification(
task_row, task_id, db, progress, project_id,
):
return {"status": TaskStatus.PENDING, "build_failed": True}

await progress.push_event(
project_id, "task_complete", task_row["title"],
task_id=task_id, cost_usd=cost_usd,
Expand Down Expand Up @@ -625,3 +638,81 @@ async def verify_csharp_build(csproj_path: str) -> tuple[bool, str]:
if error_lines:
return False, "Build errors:\n" + "\n".join(error_lines[:20])
return False, f"Build failed:\n{output[:2000]}"


async def _run_csharp_build_verification(task_row, task_id, db, progress, project_id):
"""Run C# build verification for csharp_assembly tasks.

Returns True if the task was reset (caller should return early).
Returns False if verification passed or was skipped.
"""
if not CSHARP_BUILD_VERIFY_ENABLED:
return False
try:
task_type = task_row["task_type"]
except (KeyError, IndexError):
return False
if task_type != "csharp_assembly":
return False

# Extract csproj_path from task context
csproj_path = None
try:
raw = task_row["context_json"] if "context_json" in task_row.keys() else None
ctx = json.loads(raw or "[]")
for entry in ctx:
if entry.get("type") == "csproj_path":
csproj_path = entry["content"]
break
except (json.JSONDecodeError, TypeError, KeyError):
pass

if not csproj_path:
logger.warning(
"Skipping C# build verification for task %s: no csproj_path in context",
task_id,
)
return False

success, build_output = await verify_csharp_build(csproj_path)
now = time.time()

if success:
await db.execute_write(
"UPDATE tasks SET verification_status = ?, verification_notes = ?, "
"updated_at = ? WHERE id = ?",
("passed", "Build succeeded", now, task_id),
)
return False

# Build failed — reset to PENDING with build errors as feedback
try:
retry_count = task_row["retry_count"]
except (KeyError, IndexError):
retry_count = 0
try:
ctx = json.loads(task_row["context_json"] or "[]")
except (KeyError, IndexError, json.JSONDecodeError, TypeError):
ctx = []
ctx.append({"type": "build_error_feedback", "content": build_output})

await db.execute_write(
"UPDATE tasks SET status = ?, verification_status = ?, "
"verification_notes = ?, context_json = ?, "
"claimed_by = NULL, claimed_at = NULL, "
"retry_count = retry_count + 1, updated_at = ? WHERE id = ?",
(
TaskStatus.PENDING, "failed", build_output,
json.dumps(ctx), now, task_id,
),
)
await progress.push_event(
project_id, "task_verification_retry",
f"{task_row['title']}: build failed, retrying with error feedback",
task_id=task_id,
)
logger.info(
"C# build verification failed for task %s (retry %d): %s",
task_id, retry_count + 1, build_output[:200],
)
return True
1 change: 1 addition & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"knowledge_extraction_max_tokens": 1024,
"knowledge_injection_max_chars": 3000,
"knowledge_min_output_length": 200,
"csharp_build_verify": true,
"max_history_rounds": 4,
"http_client_timeout": 300.0,
"external_claim_timeout_seconds": 3600
Expand Down
174 changes: 174 additions & 0 deletions tests/unit/test_csharp_build_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# Orchestration Engine - C# Build Verification Hook Tests
#
# Tests for _run_csharp_build_verification() in task_lifecycle.py.
#
# Depends on: backend/services/task_lifecycle.py
# Used by: CI

import json
from unittest.mock import AsyncMock, patch

import pytest

from backend.models.enums import TaskStatus


def _make_task_row(task_type="csharp_assembly", csproj_path="src/MyApp/MyApp.csproj", **overrides):
"""Build a minimal task row dict for testing."""
context = [
{"type": "task_description", "content": "Assemble methods"},
{"type": "target_class", "content": "MyApp.Services.UserService"},
]
if csproj_path:
context.append({"type": "csproj_path", "content": csproj_path})

row = {
"id": "task-1",
"title": "Assemble UserService",
"project_id": "proj-1",
"task_type": task_type,
"context_json": json.dumps(context),
"retry_count": 0,
}
row.update(overrides)
return row


@pytest.mark.asyncio
async def test_assembly_task_build_success():
"""Successful build sets verification_status=passed."""
from backend.services.task_lifecycle import _run_csharp_build_verification

db = AsyncMock()
progress = AsyncMock()
task_row = _make_task_row()

with patch("backend.services.task_lifecycle.CSHARP_BUILD_VERIFY_ENABLED", True), \
patch("backend.services.task_lifecycle.verify_csharp_build", return_value=(True, "Build succeeded")):
result = await _run_csharp_build_verification(task_row, "task-1", db, progress, "proj-1")

assert result is False # Not reset
db.execute_write.assert_called_once()
sql = db.execute_write.call_args[0][0]
assert "verification_status" in sql
params = db.execute_write.call_args[0][1]
assert params[0] == "passed"


@pytest.mark.asyncio
async def test_assembly_task_build_failure_resets_to_pending():
"""Failed build resets task to PENDING with error feedback in context."""
from backend.services.task_lifecycle import _run_csharp_build_verification

db = AsyncMock()
progress = AsyncMock()
task_row = _make_task_row()

with patch("backend.services.task_lifecycle.CSHARP_BUILD_VERIFY_ENABLED", True), \
patch("backend.services.task_lifecycle.verify_csharp_build",
return_value=(False, "Build errors:\nerror CS1002: ; expected")):
result = await _run_csharp_build_verification(task_row, "task-1", db, progress, "proj-1")

assert result is True # Task was reset
db.execute_write.assert_called_once()
sql = db.execute_write.call_args[0][0]
params = db.execute_write.call_args[0][1]
assert params[0] == TaskStatus.PENDING
assert "verification_status" in sql
assert params[1] == "failed"
assert "claimed_by = NULL" in sql
assert "claimed_at = NULL" in sql
# Build errors injected into context
new_context = json.loads(params[3])
feedback_entries = [e for e in new_context if e["type"] == "build_error_feedback"]
assert len(feedback_entries) == 1
assert "CS1002" in feedback_entries[0]["content"]

progress.push_event.assert_called_once()
event_args = progress.push_event.call_args
assert event_args[0][1] == "task_verification_retry"


@pytest.mark.asyncio
async def test_non_assembly_task_skipped():
"""Non-assembly tasks are not verified."""
from backend.services.task_lifecycle import _run_csharp_build_verification

db = AsyncMock()
progress = AsyncMock()
task_row = _make_task_row(task_type="csharp_method")

with patch("backend.services.task_lifecycle.CSHARP_BUILD_VERIFY_ENABLED", True):
result = await _run_csharp_build_verification(task_row, "task-1", db, progress, "proj-1")

assert result is False
db.execute_write.assert_not_called()


@pytest.mark.asyncio
async def test_missing_csproj_path_skipped():
"""Assembly task without csproj_path in context is skipped with warning."""
from backend.services.task_lifecycle import _run_csharp_build_verification

db = AsyncMock()
progress = AsyncMock()
task_row = _make_task_row(csproj_path=None)

with patch("backend.services.task_lifecycle.CSHARP_BUILD_VERIFY_ENABLED", True):
result = await _run_csharp_build_verification(task_row, "task-1", db, progress, "proj-1")

assert result is False
db.execute_write.assert_not_called()


@pytest.mark.asyncio
async def test_config_disabled_skips_verification():
"""When csharp_build_verify is false, verification is skipped entirely."""
from backend.services.task_lifecycle import _run_csharp_build_verification

db = AsyncMock()
progress = AsyncMock()
task_row = _make_task_row()

with patch("backend.services.task_lifecycle.CSHARP_BUILD_VERIFY_ENABLED", False):
result = await _run_csharp_build_verification(task_row, "task-1", db, progress, "proj-1")

assert result is False
db.execute_write.assert_not_called()


@pytest.mark.asyncio
async def test_decomposer_injects_csproj_path():
"""Assembly tasks created by decomposer include csproj_path from project config."""
from backend.services.decomposer import _create_csharp_assembly_tasks

tasks_data = [
{"task_type": "csharp_method", "target_class": "MyApp.UserService",
"title": "Implement GetUser", "description": "...", "affected_files": ["UserService.cs"]},
]
task_ids = ["tid-0"]
waves = [0]
phase_names = ["Phase 1"]
write_statements = []

project_row = {
"config_json": json.dumps({"csproj_path": "src/MyApp/MyApp.csproj"}),
}

_create_csharp_assembly_tasks(
tasks_data, task_ids, waves, phase_names,
"proj-1", "plan-1", 1000.0, write_statements,
project_row=project_row,
)

# Should have created 1 INSERT for the assembly task + 1 INSERT for the dep edge
assert len(write_statements) == 2
insert_sql, insert_params = write_statements[0]
assert "csharp_assembly" in str(insert_params)

# Verify csproj_path is in the context_json
context_json_str = insert_params[9] # context_json is the 10th param
context = json.loads(context_json_str)
csproj_entries = [e for e in context if e["type"] == "csproj_path"]
assert len(csproj_entries) == 1
assert csproj_entries[0]["content"] == "src/MyApp/MyApp.csproj"