Add Extend Evolve Explore lifecycle V1
This commit is contained in:
parent
ffa80f49ce
commit
0bcfc22836
7 changed files with 1452 additions and 6 deletions
543
agents/lifecycle.py
Normal file
543
agents/lifecycle.py
Normal file
|
|
@ -0,0 +1,543 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from control_plane.agents.models import ProgenySignal
|
||||||
|
from control_plane.events.bus import EventBus
|
||||||
|
from control_plane.projects.models import (
|
||||||
|
CommitRecord,
|
||||||
|
Decision,
|
||||||
|
ExtensionCandidate,
|
||||||
|
ExtensionPlan,
|
||||||
|
Exploration,
|
||||||
|
ExplorationOpportunity,
|
||||||
|
EvolutionCandidate,
|
||||||
|
EvolutionPlan,
|
||||||
|
Feature,
|
||||||
|
Finding,
|
||||||
|
Milestone,
|
||||||
|
Project,
|
||||||
|
ProjectPlan,
|
||||||
|
RoadmapItem,
|
||||||
|
StewardFinding,
|
||||||
|
Task,
|
||||||
|
TaskDependency,
|
||||||
|
TaskStatus,
|
||||||
|
)
|
||||||
|
from control_plane.verification.models import Review, TestRun, Verification, VerificationLevel, VerificationResult
|
||||||
|
from graph.langgraph_runtime import LangGraphRuntime
|
||||||
|
from graph.models import GraphRun
|
||||||
|
from graph.task_nodes import TaskExecutionServices, task_execution_registry
|
||||||
|
from model_router.router import ModelCapability, ModelRequestContract, ModelRouter
|
||||||
|
from project_brain.planning import ProjectPlanContract, parse_project_plan_response
|
||||||
|
|
||||||
|
|
||||||
|
class LifecyclePlanningError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectContextMixin:
|
||||||
|
def project_context(self, project: Project) -> dict[str, object]:
|
||||||
|
files: dict[str, str] = {}
|
||||||
|
tests: dict[str, str] = {}
|
||||||
|
if project.repository_path:
|
||||||
|
root = Path(project.repository_path)
|
||||||
|
for path in sorted(root.rglob("*.py"))[:40]:
|
||||||
|
if any(part in path.parts for part in [".git", "__pycache__", "migrations", ".venv", "venv", "node_modules", ".pytest_cache"]):
|
||||||
|
continue
|
||||||
|
relative = path.relative_to(root).as_posix()
|
||||||
|
content = path.read_text(encoding="utf-8", errors="ignore")[:3000]
|
||||||
|
if relative.startswith("tests") or "test" in path.name:
|
||||||
|
tests[relative] = content
|
||||||
|
else:
|
||||||
|
files[relative] = content
|
||||||
|
return {
|
||||||
|
"project": {"id": str(project.id), "name": project.name, "goal": project.goal, "architecture_summary": project.architecture_summary},
|
||||||
|
"decisions": list(project.decisions.order_by("-created_at").values("decision_type", "decision", "reason")[:20]),
|
||||||
|
"roadmap_items": list(project.roadmap_items.order_by("-created_at").values("title", "description", "status", "source")[:20]),
|
||||||
|
"findings": list(project.findings.order_by("-created_at").values("finding_type", "severity", "title", "status")[:20]),
|
||||||
|
"steward_findings": list(project.steward_findings.order_by("-created_at").values("finding_type", "severity", "title", "status", "recommended_action")[:20]),
|
||||||
|
"features": list(project.features.order_by("created_at").values("title", "description", "status", "acceptance_criteria")[:50]),
|
||||||
|
"tasks": list(project.tasks.order_by("-created_at").values("task_type", "status", "goal", "acceptance_criteria")[:50]),
|
||||||
|
"files": files,
|
||||||
|
"tests": tests,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _json_from_sol(self, router: ModelRouter | None, prompt: str, *, fallback: dict[str, object], project: Project | None = None) -> dict[str, object]:
|
||||||
|
if router is None:
|
||||||
|
return fallback
|
||||||
|
response = router.complete(ModelRequestContract(purpose=ModelCapability.PLANNING, prompt=prompt, model_hint="sol", project=project))
|
||||||
|
try:
|
||||||
|
payload = json.loads(response.content)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise LifecyclePlanningError("Sol lifecycle planning response must be JSON") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise LifecyclePlanningError("Sol lifecycle planning response must be a JSON object")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _json_from_project_brain(self, router: ModelRouter | None, prompt: str, *, fallback: dict[str, object], project: Project, category: str) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
return self._json_from_sol(router, prompt, fallback=fallback, project=project)
|
||||||
|
except Exception as exc:
|
||||||
|
create_project_planning_signal(project, f"Sol {category} planning response invalid; using bounded fallback plan.", {"error": str(exc), "category": category})
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionService(ProjectContextMixin):
|
||||||
|
def __init__(self, router: ModelRouter | None = None, bus: EventBus | None = None) -> None:
|
||||||
|
self.router = router
|
||||||
|
self.bus = bus or EventBus()
|
||||||
|
|
||||||
|
def create_candidate(
|
||||||
|
self,
|
||||||
|
project: Project,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
|
rationale: str = "",
|
||||||
|
source: str = "user",
|
||||||
|
expected_value: str = "",
|
||||||
|
affected_areas: list[str] | None = None,
|
||||||
|
estimated_complexity: str = "MEDIUM",
|
||||||
|
risk: str = "MEDIUM",
|
||||||
|
confidence: float = 0.5,
|
||||||
|
evidence: dict[str, object] | None = None,
|
||||||
|
source_steward_finding: StewardFinding | None = None,
|
||||||
|
source_opportunity: ExplorationOpportunity | None = None,
|
||||||
|
) -> ExtensionCandidate:
|
||||||
|
return ExtensionCandidate.objects.create(
|
||||||
|
project=project,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
rationale=rationale,
|
||||||
|
source=source,
|
||||||
|
expected_value=expected_value,
|
||||||
|
affected_areas=affected_areas or [],
|
||||||
|
estimated_complexity=estimated_complexity,
|
||||||
|
risk=risk,
|
||||||
|
confidence=confidence,
|
||||||
|
evidence=evidence or {},
|
||||||
|
source_steward_finding=source_steward_finding,
|
||||||
|
source_opportunity=source_opportunity,
|
||||||
|
)
|
||||||
|
|
||||||
|
def plan_with_project_brain(self, candidate: ExtensionCandidate) -> ExtensionPlan:
|
||||||
|
context = self.project_context(candidate.project)
|
||||||
|
fallback = self._fallback_extension_plan(candidate, context)
|
||||||
|
payload = self._json_from_project_brain(
|
||||||
|
self.router,
|
||||||
|
"Plan an EXTENSION for an existing project. Return JSON with extension_plan and project_plan. Do not treat this as greenfield.\n"
|
||||||
|
+ json.dumps({"candidate": self._candidate_payload(candidate), "context": context}, default=str),
|
||||||
|
fallback=fallback,
|
||||||
|
project=candidate.project,
|
||||||
|
category="extension",
|
||||||
|
)
|
||||||
|
raw_plan = payload.get("extension_plan", payload)
|
||||||
|
if not isinstance(raw_plan, dict):
|
||||||
|
raise LifecyclePlanningError("extension_plan must be an object")
|
||||||
|
project_plan_payload = raw_plan.get("project_plan") or payload.get("project_plan")
|
||||||
|
if not isinstance(project_plan_payload, dict):
|
||||||
|
create_project_planning_signal(candidate.project, "Sol extension plan omitted project_plan; using bounded fallback plan.", {"candidate_id": str(candidate.id)})
|
||||||
|
raw_plan = dict(fallback["extension_plan"])
|
||||||
|
project_plan_payload = dict(raw_plan["project_plan"])
|
||||||
|
try:
|
||||||
|
parse_project_plan_response(json.dumps(project_plan_payload))
|
||||||
|
except Exception as exc:
|
||||||
|
create_project_planning_signal(candidate.project, "Sol extension project_plan failed validation; using bounded fallback plan.", {"candidate_id": str(candidate.id), "error": str(exc)})
|
||||||
|
raw_plan = dict(fallback["extension_plan"])
|
||||||
|
project_plan_payload = dict(raw_plan["project_plan"])
|
||||||
|
parse_project_plan_response(json.dumps(project_plan_payload))
|
||||||
|
plan = ExtensionPlan.objects.create(
|
||||||
|
candidate=candidate,
|
||||||
|
project=candidate.project,
|
||||||
|
status="PLANNED",
|
||||||
|
strategy=str(raw_plan.get("strategy", candidate.description)),
|
||||||
|
plan=raw_plan,
|
||||||
|
acceptance_criteria=[str(item) for item in raw_plan.get("acceptance_criteria", project_plan_payload.get("acceptance_criteria", []))],
|
||||||
|
context_snapshot=context,
|
||||||
|
)
|
||||||
|
candidate.status = "PLANNING"
|
||||||
|
candidate.save(update_fields=["status", "updated_at"])
|
||||||
|
self.bus.publish("EXTENSION_PLAN_CREATED", project=candidate.project, payload={"candidate_id": str(candidate.id), "plan_id": str(plan.id)})
|
||||||
|
return plan
|
||||||
|
|
||||||
|
def approve_plan(self, plan: ExtensionPlan) -> ExtensionPlan:
|
||||||
|
plan.status = "APPROVED"
|
||||||
|
plan.approved_at = timezone.now()
|
||||||
|
plan.save(update_fields=["status", "approved_at", "updated_at"])
|
||||||
|
plan.candidate.status = "READY"
|
||||||
|
plan.candidate.save(update_fields=["status", "updated_at"])
|
||||||
|
return plan
|
||||||
|
|
||||||
|
def materialize_project_dag(self, plan: ExtensionPlan) -> ProjectPlan:
|
||||||
|
if plan.project_plan_id:
|
||||||
|
return plan.project_plan
|
||||||
|
contract = parse_project_plan_response(json.dumps(plan.plan.get("project_plan", {})))
|
||||||
|
project_plan = self._materialize_contract(plan.project, contract, prefix=f"EXT-{str(plan.id)[:8]}")
|
||||||
|
plan.project_plan = project_plan
|
||||||
|
plan.status = "MATERIALIZED"
|
||||||
|
plan.save(update_fields=["project_plan", "status", "updated_at"])
|
||||||
|
plan.candidate.status = "BUILDING"
|
||||||
|
plan.candidate.save(update_fields=["status", "updated_at"])
|
||||||
|
return project_plan
|
||||||
|
|
||||||
|
def execute(self, plan: ExtensionPlan, router: ModelRouter, *, test_command: list[str] | None = None) -> list[GraphRun]:
|
||||||
|
from graph.bootstrap import champion_task_execution_graph_v1
|
||||||
|
|
||||||
|
project_plan = self.materialize_project_dag(plan)
|
||||||
|
runs: list[GraphRun] = []
|
||||||
|
for task in Task.objects.filter(milestone__plan=project_plan).exclude(status=TaskStatus.COMPLETE).order_by("priority", "created_at"):
|
||||||
|
graph_version = champion_task_execution_graph_v1()
|
||||||
|
graph_run = GraphRun.objects.create(execution_graph_version=graph_version, project=task.project, milestone=task.milestone, feature=task.feature, task=task, current_node=graph_version.graph_spec["entry"], metadata={"extension_plan_id": str(plan.id), "extension_candidate_id": str(plan.candidate_id)})
|
||||||
|
LangGraphRuntime(task_execution_registry(TaskExecutionServices(router, bus=self.bus, test_command=test_command or ["python", "-m", "pytest"])), bus=self.bus).run_until_terminal_or_paused(graph_run)
|
||||||
|
for commit in task.commits.all():
|
||||||
|
commit.extension_candidate = plan.candidate
|
||||||
|
commit.save(update_fields=["extension_candidate", "updated_at"])
|
||||||
|
runs.append(graph_run)
|
||||||
|
return runs
|
||||||
|
|
||||||
|
def verify_extension(self, plan: ExtensionPlan) -> Verification:
|
||||||
|
project_plan = plan.project_plan
|
||||||
|
tasks = Task.objects.filter(milestone__plan=project_plan) if project_plan else Task.objects.none()
|
||||||
|
task_count = tasks.count()
|
||||||
|
completed = task_count > 0 and not tasks.exclude(status=TaskStatus.COMPLETE).exists()
|
||||||
|
tests_pass = not TestRun.objects.filter(task__in=tasks).exclude(status="PASS").exists()
|
||||||
|
review_failures = Review.objects.filter(task__in=tasks).exclude(status="PASS")
|
||||||
|
judge_failures = Verification.objects.filter(task__in=tasks, level=VerificationLevel.TASK).exclude(result=VerificationResult.PASS)
|
||||||
|
criteria = plan.acceptance_criteria or plan.plan.get("acceptance_criteria", [])
|
||||||
|
criteria_present = bool(criteria)
|
||||||
|
passed = completed and tests_pass and not review_failures.exists() and not judge_failures.exists() and criteria_present
|
||||||
|
verification = Verification.objects.create(
|
||||||
|
project=plan.project,
|
||||||
|
milestone=tasks.first().milestone if tasks.exists() else None,
|
||||||
|
level=VerificationLevel.MILESTONE,
|
||||||
|
result=VerificationResult.PASS if passed else VerificationResult.FAIL,
|
||||||
|
contract={"extension_plan_id": str(plan.id), "acceptance_criteria": criteria},
|
||||||
|
evidence=[{"task_count": task_count, "all_tasks_complete": completed, "tests_pass": tests_pass, "review_failures": review_failures.count(), "judge_failures": judge_failures.count()}],
|
||||||
|
summary="Extension acceptance contract satisfied" if passed else "Extension acceptance contract failed",
|
||||||
|
)
|
||||||
|
plan.verification = verification
|
||||||
|
plan.status = "COMPLETE" if passed else "FAILED"
|
||||||
|
plan.completed_at = timezone.now()
|
||||||
|
plan.save(update_fields=["verification", "status", "completed_at", "updated_at"])
|
||||||
|
plan.candidate.status = "COMPLETE" if passed else "BUILDING"
|
||||||
|
plan.candidate.save(update_fields=["status", "updated_at"])
|
||||||
|
return verification
|
||||||
|
|
||||||
|
def _fallback_extension_plan(self, candidate: ExtensionCandidate, context: dict[str, object]) -> dict[str, object]:
|
||||||
|
task_goal = candidate.metadata.get("task_goal") or candidate.description or candidate.title
|
||||||
|
acceptance = candidate.metadata.get("acceptance_criteria") or [f"{candidate.title} capability is present", "Deterministic tests pass"]
|
||||||
|
return {
|
||||||
|
"extension_plan": {
|
||||||
|
"strategy": f"Add bounded scope to existing project: {candidate.title}",
|
||||||
|
"acceptance_criteria": acceptance,
|
||||||
|
"project_plan": {
|
||||||
|
"goal": candidate.project.goal,
|
||||||
|
"scope": candidate.description,
|
||||||
|
"acceptance_criteria": acceptance,
|
||||||
|
"milestones": [
|
||||||
|
{
|
||||||
|
"key": "EXTEND",
|
||||||
|
"title": candidate.title,
|
||||||
|
"goal": candidate.description or candidate.title,
|
||||||
|
"verification_contract": {"extension_candidate_id": str(candidate.id)},
|
||||||
|
"features": [{"key": "F1", "title": candidate.title, "description": candidate.description, "acceptance_criteria": acceptance, "tasks": [{"id": "T1", "goal": str(task_goal), "type": "implementation", "acceptance_criteria": acceptance, "priority": 50, "dependencies": []}]}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def _candidate_payload(self, candidate: ExtensionCandidate) -> dict[str, object]:
|
||||||
|
return {"id": str(candidate.id), "title": candidate.title, "description": candidate.description, "rationale": candidate.rationale, "expected_value": candidate.expected_value, "affected_areas": candidate.affected_areas, "evidence": candidate.evidence}
|
||||||
|
|
||||||
|
def _materialize_contract(self, project: Project, contract: ProjectPlanContract, *, prefix: str) -> ProjectPlan:
|
||||||
|
with transaction.atomic():
|
||||||
|
version = project.current_plan_version + 1
|
||||||
|
project_plan = ProjectPlan.objects.create(project=project, version=version, goal=project.goal, scope=contract.scope, stack=contract.stack, architecture=contract.architecture, constraints=contract.constraints, acceptance_criteria=contract.acceptance_criteria, permissions=contract.permissions, budget=contract.budget, open_decisions=contract.open_decisions, approved_at=timezone.now())
|
||||||
|
project.current_plan_version = version
|
||||||
|
project.save(update_fields=["current_plan_version", "updated_at"])
|
||||||
|
task_by_external_id: dict[str, Task] = {}
|
||||||
|
dependency_specs: list[tuple[Task, list[str]]] = []
|
||||||
|
for order, milestone_contract in enumerate(contract.milestones):
|
||||||
|
milestone = Milestone.objects.create(project=project, plan=project_plan, key=f"{prefix}-{milestone_contract.key}"[:50], title=milestone_contract.title, goal=milestone_contract.goal, verification_contract=milestone_contract.verification_contract, order=order)
|
||||||
|
for feature_contract in milestone_contract.features:
|
||||||
|
feature = Feature.objects.create(project=project, milestone=milestone, title=feature_contract.title, description=feature_contract.description, acceptance_criteria=feature_contract.acceptance_criteria)
|
||||||
|
for task_contract in feature_contract.tasks:
|
||||||
|
task = Task.objects.create(project=project, milestone=milestone, feature=feature, task_type=task_contract.task_type, status=TaskStatus.READY, priority=task_contract.priority, goal=task_contract.goal, acceptance_criteria=task_contract.acceptance_criteria)
|
||||||
|
task_by_external_id[task_contract.task_id] = task
|
||||||
|
dependency_specs.append((task, task_contract.dependencies))
|
||||||
|
for task, deps in dependency_specs:
|
||||||
|
for dep in deps:
|
||||||
|
TaskDependency.objects.create(task=task, depends_on=task_by_external_id[dep])
|
||||||
|
return project_plan
|
||||||
|
|
||||||
|
|
||||||
|
class EvolutionService(ProjectContextMixin):
|
||||||
|
def __init__(self, router: ModelRouter | None = None, bus: EventBus | None = None) -> None:
|
||||||
|
self.router = router
|
||||||
|
self.bus = bus or EventBus()
|
||||||
|
|
||||||
|
def create_candidate(self, project: Project, *, target: str, objective: str, baseline_measurement: dict[str, object], desired_direction: str, target_measurement: dict[str, object] | None = None, rationale: str = "", source: str = "user", evidence: dict[str, object] | None = None, risk: str = "MEDIUM", confidence: float = 0.5, source_steward_finding: StewardFinding | None = None, source_opportunity: ExplorationOpportunity | None = None) -> EvolutionCandidate:
|
||||||
|
if not baseline_measurement:
|
||||||
|
raise ValidationError("EVOLVE requires a measurable baseline; route to INVESTIGATE or EXPLORE instead.")
|
||||||
|
return EvolutionCandidate.objects.create(project=project, target=target, objective=objective, baseline_measurement=baseline_measurement, desired_direction=desired_direction, target_measurement=target_measurement or {}, rationale=rationale, source=source, evidence=evidence or {}, risk=risk, confidence=confidence, source_steward_finding=source_steward_finding, source_opportunity=source_opportunity)
|
||||||
|
|
||||||
|
def plan_with_project_brain(self, candidate: EvolutionCandidate) -> EvolutionPlan:
|
||||||
|
if not candidate.baseline_measurement:
|
||||||
|
raise ValidationError("EvolutionCandidate has no baseline measurement.")
|
||||||
|
context = self.project_context(candidate.project)
|
||||||
|
fallback = self._fallback_evolution_plan(candidate)
|
||||||
|
payload = self._json_from_project_brain(self.router, "Plan an EVOLUTION for an existing project. Return JSON with evolution_plan and project_plan. Must preserve measurable baseline and threshold.\n" + json.dumps({"candidate": self._candidate_payload(candidate), "context": context}, default=str), fallback=fallback, project=candidate.project, category="evolution")
|
||||||
|
raw_plan = payload.get("evolution_plan", payload)
|
||||||
|
if not isinstance(raw_plan, dict):
|
||||||
|
raise LifecyclePlanningError("evolution_plan must be an object")
|
||||||
|
project_plan_payload = raw_plan.get("project_plan") or payload.get("project_plan")
|
||||||
|
if not isinstance(project_plan_payload, dict):
|
||||||
|
create_project_planning_signal(candidate.project, "Sol evolution plan omitted project_plan; using bounded fallback plan.", {"candidate_id": str(candidate.id)})
|
||||||
|
raw_plan = dict(fallback["evolution_plan"])
|
||||||
|
project_plan_payload = dict(raw_plan["project_plan"])
|
||||||
|
try:
|
||||||
|
parse_project_plan_response(json.dumps(project_plan_payload))
|
||||||
|
except Exception as exc:
|
||||||
|
create_project_planning_signal(candidate.project, "Sol evolution project_plan failed validation; using bounded fallback plan.", {"candidate_id": str(candidate.id), "error": str(exc)})
|
||||||
|
raw_plan = dict(fallback["evolution_plan"])
|
||||||
|
project_plan_payload = dict(raw_plan["project_plan"])
|
||||||
|
parse_project_plan_response(json.dumps(project_plan_payload))
|
||||||
|
plan = EvolutionPlan.objects.create(candidate=candidate, project=candidate.project, status="PLANNED", baseline=dict(raw_plan.get("baseline", candidate.baseline_measurement)), hypothesis=str(raw_plan.get("hypothesis", candidate.objective)), intervention=str(raw_plan.get("intervention", "Implement targeted project improvement")), measurement_method=dict(raw_plan.get("measurement_method", {"type": "metadata"})), success_threshold=dict(raw_plan.get("success_threshold", {"minimum_improvement_percent": 10})), regression_constraints=list(raw_plan.get("regression_constraints", ["Deterministic tests pass"])), affected_components=list(raw_plan.get("affected_components", [candidate.target])), task_plan=project_plan_payload, experiment_requirements=dict(raw_plan.get("experiment_requirements", {})), context_snapshot=context)
|
||||||
|
candidate.status = "PLANNING"
|
||||||
|
candidate.save(update_fields=["status", "updated_at"])
|
||||||
|
return plan
|
||||||
|
|
||||||
|
def approve_plan(self, plan: EvolutionPlan) -> EvolutionPlan:
|
||||||
|
plan.status = "APPROVED"
|
||||||
|
plan.approved_at = timezone.now()
|
||||||
|
plan.save(update_fields=["status", "approved_at", "updated_at"])
|
||||||
|
plan.candidate.status = "READY"
|
||||||
|
plan.candidate.save(update_fields=["status", "updated_at"])
|
||||||
|
return plan
|
||||||
|
|
||||||
|
def materialize_work(self, plan: EvolutionPlan) -> ProjectPlan:
|
||||||
|
if plan.project_plan_id:
|
||||||
|
return plan.project_plan
|
||||||
|
contract = parse_project_plan_response(json.dumps(plan.task_plan))
|
||||||
|
project_plan = ExtensionService(bus=self.bus)._materialize_contract(plan.project, contract, prefix=f"EVO-{str(plan.id)[:8]}")
|
||||||
|
plan.project_plan = project_plan
|
||||||
|
plan.status = "MATERIALIZED"
|
||||||
|
plan.save(update_fields=["project_plan", "status", "updated_at"])
|
||||||
|
plan.candidate.status = "BUILDING"
|
||||||
|
plan.candidate.save(update_fields=["status", "updated_at"])
|
||||||
|
return project_plan
|
||||||
|
|
||||||
|
def execute(self, plan: EvolutionPlan, router: ModelRouter, *, test_command: list[str] | None = None) -> list[GraphRun]:
|
||||||
|
from graph.bootstrap import champion_task_execution_graph_v1
|
||||||
|
|
||||||
|
project_plan = self.materialize_work(plan)
|
||||||
|
runs: list[GraphRun] = []
|
||||||
|
for task in Task.objects.filter(milestone__plan=project_plan).exclude(status=TaskStatus.COMPLETE).order_by("priority", "created_at"):
|
||||||
|
graph_version = champion_task_execution_graph_v1()
|
||||||
|
graph_run = GraphRun.objects.create(execution_graph_version=graph_version, project=task.project, milestone=task.milestone, feature=task.feature, task=task, current_node=graph_version.graph_spec["entry"], metadata={"evolution_plan_id": str(plan.id), "evolution_candidate_id": str(plan.candidate_id)})
|
||||||
|
LangGraphRuntime(task_execution_registry(TaskExecutionServices(router, bus=self.bus, test_command=test_command or ["python", "-m", "pytest"])), bus=self.bus).run_until_terminal_or_paused(graph_run)
|
||||||
|
for commit in task.commits.all():
|
||||||
|
commit.evolution_candidate = plan.candidate
|
||||||
|
commit.save(update_fields=["evolution_candidate", "updated_at"])
|
||||||
|
runs.append(graph_run)
|
||||||
|
return runs
|
||||||
|
|
||||||
|
def measure_candidate(self, plan: EvolutionPlan) -> dict[str, object]:
|
||||||
|
method = plan.measurement_method or {}
|
||||||
|
if "candidate_measurement" in plan.metadata:
|
||||||
|
measurement = dict(plan.metadata["candidate_measurement"])
|
||||||
|
elif method.get("type") == "command" and plan.project.repository_path:
|
||||||
|
completed = subprocess.run([str(part) for part in method.get("command", [])], cwd=plan.project.repository_path, capture_output=True, text=True, check=False, timeout=120)
|
||||||
|
measurement = {"returncode": completed.returncode, "stdout": completed.stdout[-4000:], "stderr": completed.stderr[-2000:]}
|
||||||
|
else:
|
||||||
|
measurement = dict(plan.candidate.target_measurement or plan.baseline)
|
||||||
|
plan.candidate_measurement = measurement
|
||||||
|
plan.save(update_fields=["candidate_measurement", "updated_at"])
|
||||||
|
return measurement
|
||||||
|
|
||||||
|
def compare_baseline(self, plan: EvolutionPlan) -> dict[str, object]:
|
||||||
|
metric = str(plan.success_threshold.get("metric", plan.baseline.get("metric", "value")))
|
||||||
|
baseline_value = float(plan.baseline.get(metric, plan.baseline.get("value", 0)) or 0)
|
||||||
|
candidate_value = float(plan.candidate_measurement.get(metric, plan.candidate_measurement.get("value", baseline_value)) or 0)
|
||||||
|
direction = plan.candidate.desired_direction
|
||||||
|
if baseline_value == 0:
|
||||||
|
improvement_percent = 0.0
|
||||||
|
elif direction == "DECREASE":
|
||||||
|
improvement_percent = ((baseline_value - candidate_value) / baseline_value) * 100
|
||||||
|
else:
|
||||||
|
improvement_percent = ((candidate_value - baseline_value) / baseline_value) * 100
|
||||||
|
threshold = float(plan.success_threshold.get("minimum_improvement_percent", 0))
|
||||||
|
delta = {"metric": metric, "baseline": baseline_value, "candidate": candidate_value, "improvement_percent": improvement_percent, "threshold": threshold}
|
||||||
|
plan.delta = delta
|
||||||
|
plan.save(update_fields=["delta", "updated_at"])
|
||||||
|
return delta
|
||||||
|
|
||||||
|
def judge_evolution(self, plan: EvolutionPlan) -> Verification:
|
||||||
|
if not plan.candidate_measurement:
|
||||||
|
self.measure_candidate(plan)
|
||||||
|
delta = self.compare_baseline(plan)
|
||||||
|
project_plan = plan.project_plan
|
||||||
|
tasks = Task.objects.filter(milestone__plan=project_plan) if project_plan else Task.objects.none()
|
||||||
|
tests_pass = not TestRun.objects.filter(task__in=tasks).exclude(status="PASS").exists()
|
||||||
|
implementation_complete = not tasks.exclude(status=TaskStatus.COMPLETE).exists() if tasks.exists() else True
|
||||||
|
improved = float(delta["improvement_percent"]) >= float(delta["threshold"])
|
||||||
|
verdict = "PASS" if implementation_complete and tests_pass and improved else "NOT_IMPROVED"
|
||||||
|
verification = Verification.objects.create(project=plan.project, milestone=tasks.first().milestone if tasks.exists() else None, level=VerificationLevel.MILESTONE, result=VerificationResult.PASS if verdict == "PASS" else VerificationResult.FAIL, contract={"evolution_plan_id": str(plan.id), "success_threshold": plan.success_threshold, "regression_constraints": plan.regression_constraints}, evidence=[{"implementation_complete": implementation_complete, "tests_pass": tests_pass, "delta": delta, "verdict": verdict}], summary="Evolution objective improved" if verdict == "PASS" else "Evolution implementation did not improve objective")
|
||||||
|
plan.verification = verification
|
||||||
|
plan.verdict = verdict
|
||||||
|
plan.status = "COMPLETE" if verdict == "PASS" else "NOT_IMPROVED"
|
||||||
|
plan.completed_at = timezone.now()
|
||||||
|
plan.save(update_fields=["verification", "verdict", "status", "completed_at", "updated_at"])
|
||||||
|
plan.candidate.status = "COMPLETE" if verdict == "PASS" else "PROPOSED"
|
||||||
|
plan.candidate.save(update_fields=["status", "updated_at"])
|
||||||
|
return verification
|
||||||
|
|
||||||
|
def _fallback_evolution_plan(self, candidate: EvolutionCandidate) -> dict[str, object]:
|
||||||
|
acceptance = ["Deterministic tests pass", f"Objective improves: {candidate.objective}"]
|
||||||
|
task_goal = candidate.metadata.get("task_goal") or f"Improve {candidate.target}: {candidate.objective}"
|
||||||
|
return {"evolution_plan": {"baseline": candidate.baseline_measurement, "hypothesis": candidate.objective, "intervention": str(task_goal), "measurement_method": {"type": "metadata"}, "success_threshold": {"metric": candidate.baseline_measurement.get("metric", "value"), "minimum_improvement_percent": 10}, "regression_constraints": ["Deterministic tests pass"], "affected_components": [candidate.target], "project_plan": {"goal": candidate.project.goal, "scope": candidate.objective, "acceptance_criteria": acceptance, "milestones": [{"key": "EVOLVE", "title": candidate.target, "goal": candidate.objective, "verification_contract": {"evolution_candidate_id": str(candidate.id)}, "features": [{"key": "F1", "title": candidate.target, "description": candidate.objective, "acceptance_criteria": acceptance, "tasks": [{"id": "T1", "goal": str(task_goal), "type": "implementation", "acceptance_criteria": acceptance, "priority": 50, "dependencies": []}]}]}]}}}
|
||||||
|
|
||||||
|
def _candidate_payload(self, candidate: EvolutionCandidate) -> dict[str, object]:
|
||||||
|
return {"id": str(candidate.id), "target": candidate.target, "objective": candidate.objective, "baseline_measurement": candidate.baseline_measurement, "desired_direction": candidate.desired_direction, "target_measurement": candidate.target_measurement, "evidence": candidate.evidence}
|
||||||
|
|
||||||
|
|
||||||
|
class ExplorerService(ProjectContextMixin):
|
||||||
|
def __init__(self, router: ModelRouter | None = None, bus: EventBus | None = None) -> None:
|
||||||
|
self.router = router
|
||||||
|
self.bus = bus or EventBus()
|
||||||
|
|
||||||
|
def start_exploration(self, project: Project, *, prompt: str = "") -> Exploration:
|
||||||
|
exploration = Exploration.objects.create(project=project, prompt=prompt, context_snapshot=self.project_context(project))
|
||||||
|
return exploration
|
||||||
|
|
||||||
|
def generate_opportunities(self, exploration: Exploration) -> list[ExplorationOpportunity]:
|
||||||
|
fallback = {"opportunities": self._fallback_opportunities(exploration)}
|
||||||
|
payload = self._json_from_project_brain(self.router, "Explore an existing project for valuable changes. Return JSON opportunities only; do not create build work.\n" + json.dumps(exploration.context_snapshot, default=str), fallback=fallback, project=exploration.project, category="exploration")
|
||||||
|
raw_items = payload.get("opportunities", [])
|
||||||
|
if not isinstance(raw_items, list):
|
||||||
|
raise LifecyclePlanningError("Explorer response opportunities must be a list")
|
||||||
|
opportunities: list[ExplorationOpportunity] = []
|
||||||
|
for raw in raw_items:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
opportunity = self.upsert_opportunity(exploration, raw)
|
||||||
|
opportunities.append(opportunity)
|
||||||
|
exploration.status = "COMPLETE"
|
||||||
|
exploration.completed_at = timezone.now()
|
||||||
|
exploration.save(update_fields=["status", "completed_at", "updated_at"])
|
||||||
|
return sorted(opportunities, key=lambda item: item.composite_score, reverse=True)
|
||||||
|
|
||||||
|
def upsert_opportunity(self, exploration: Exploration, raw: dict[str, object]) -> ExplorationOpportunity:
|
||||||
|
title = str(raw.get("title", "Untitled opportunity"))
|
||||||
|
grouping_key = self._grouping_key(exploration.project, title, str(raw.get("opportunity_type", "FEATURE")))
|
||||||
|
existing = self._known_duplicate(exploration.project, grouping_key, title)
|
||||||
|
scores = self.score(raw)
|
||||||
|
raw_evidence = raw.get("evidence", {})
|
||||||
|
evidence = raw_evidence if isinstance(raw_evidence, dict) else {"raw": raw_evidence}
|
||||||
|
if existing is not None:
|
||||||
|
existing.metadata = {**existing.metadata, "rediscovered_by": str(exploration.id)}
|
||||||
|
existing.save(update_fields=["metadata", "updated_at"])
|
||||||
|
return existing
|
||||||
|
return ExplorationOpportunity.objects.create(exploration=exploration, project=exploration.project, title=title, description=str(raw.get("description", "")), opportunity_type=str(raw.get("opportunity_type", "FEATURE")), evidence=evidence, rationale=str(raw.get("rationale", "")), expected_value=str(raw.get("expected_value", "")), effort_estimate=str(raw.get("effort_estimate", "MEDIUM")), risk=str(raw.get("risk", "MEDIUM")), confidence=scores["confidence"], technical_fit=scores["technical_fit"], strategic_fit=scores["strategic_fit"], value_score=scores["value"], effort_score=scores["effort"], risk_score=scores["risk"], composite_score=scores["composite"], recommended_action=str(raw.get("recommended_action", "DEFER")), grouping_key=grouping_key)
|
||||||
|
|
||||||
|
def score(self, raw: dict[str, object]) -> dict[str, float]:
|
||||||
|
value = self._score_value(raw.get("value", raw.get("value_score", 0.5)))
|
||||||
|
effort = self._score_value(raw.get("effort", raw.get("effort_score", 0.5)))
|
||||||
|
risk = self._score_value(raw.get("risk_score", 0.5))
|
||||||
|
confidence = self._score_value(raw.get("confidence", 0.5))
|
||||||
|
technical_fit = self._score_value(raw.get("technical_fit", 0.5))
|
||||||
|
strategic_fit = self._score_value(raw.get("strategic_fit", 0.5))
|
||||||
|
composite = (value * 0.35) + ((1 - effort) * 0.15) + ((1 - risk) * 0.15) + (confidence * 0.15) + (technical_fit * 0.1) + (strategic_fit * 0.1)
|
||||||
|
return {"value": value, "effort": effort, "risk": risk, "confidence": confidence, "technical_fit": technical_fit, "strategic_fit": strategic_fit, "composite": composite}
|
||||||
|
|
||||||
|
def _score_value(self, value: object) -> float:
|
||||||
|
try:
|
||||||
|
score = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.5
|
||||||
|
return max(0.0, min(1.0, score))
|
||||||
|
|
||||||
|
def convert_to_extension(self, opportunity: ExplorationOpportunity) -> ExtensionCandidate:
|
||||||
|
candidate = ExtensionService(bus=self.bus).create_candidate(opportunity.project, title=opportunity.title, description=opportunity.description, rationale=opportunity.rationale, source="Explorer", expected_value=opportunity.expected_value, affected_areas=[opportunity.opportunity_type], estimated_complexity=opportunity.effort_estimate, risk=opportunity.risk, confidence=opportunity.confidence, evidence=opportunity.evidence, source_opportunity=opportunity)
|
||||||
|
opportunity.converted_extension = candidate
|
||||||
|
opportunity.status = "CONVERTED"
|
||||||
|
opportunity.save(update_fields=["converted_extension", "status", "updated_at"])
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
def convert_to_evolution(self, opportunity: ExplorationOpportunity, *, baseline_measurement: dict[str, object], desired_direction: str = "DECREASE") -> EvolutionCandidate:
|
||||||
|
candidate = EvolutionService(bus=self.bus).create_candidate(opportunity.project, target=opportunity.opportunity_type, objective=opportunity.description or opportunity.title, baseline_measurement=baseline_measurement, desired_direction=desired_direction, rationale=opportunity.rationale, source="Explorer", evidence=opportunity.evidence, risk=opportunity.risk, confidence=opportunity.confidence, source_opportunity=opportunity)
|
||||||
|
opportunity.converted_evolution = candidate
|
||||||
|
opportunity.status = "CONVERTED"
|
||||||
|
opportunity.save(update_fields=["converted_evolution", "status", "updated_at"])
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
def defer(self, opportunity: ExplorationOpportunity) -> ExplorationOpportunity:
|
||||||
|
opportunity.status = "DEFERRED"
|
||||||
|
opportunity.save(update_fields=["status", "updated_at"])
|
||||||
|
return opportunity
|
||||||
|
|
||||||
|
def reject(self, opportunity: ExplorationOpportunity) -> ExplorationOpportunity:
|
||||||
|
opportunity.status = "REJECTED"
|
||||||
|
opportunity.save(update_fields=["status", "updated_at"])
|
||||||
|
return opportunity
|
||||||
|
|
||||||
|
def _fallback_opportunities(self, exploration: Exploration) -> list[dict[str, object]]:
|
||||||
|
context = exploration.context_snapshot
|
||||||
|
features = str(context.get("features", "")).lower()
|
||||||
|
opportunities: list[dict[str, object]] = []
|
||||||
|
if "dashboard" not in features:
|
||||||
|
opportunities.append({"title": "Add project health dashboard", "description": "Expose recent runs, findings, and lifecycle status in a project dashboard.", "opportunity_type": "FEATURE", "evidence": {"missing_feature": "dashboard"}, "rationale": "Operators need a fast project health view.", "expected_value": "Improves observability", "effort_estimate": "MEDIUM", "value": 0.8, "effort": 0.45, "risk_score": 0.3, "confidence": 0.7, "technical_fit": 0.8, "strategic_fit": 0.8, "recommended_action": "EXTEND"})
|
||||||
|
opportunities.append({"title": "Measure repository analysis latency", "description": "Establish and improve latency for repeated project inspection.", "opportunity_type": "PERFORMANCE", "evidence": {"source": "Explorer fallback"}, "rationale": "Faster analysis improves iteration speed.", "expected_value": "Reduces operational latency", "effort_estimate": "LOW", "value": 0.6, "effort": 0.25, "risk_score": 0.2, "confidence": 0.65, "technical_fit": 0.75, "strategic_fit": 0.65, "recommended_action": "EVOLVE"})
|
||||||
|
return opportunities
|
||||||
|
|
||||||
|
def _known_duplicate(self, project: Project, grouping_key: str, title: str) -> ExplorationOpportunity | None:
|
||||||
|
existing = ExplorationOpportunity.objects.filter(project=project, grouping_key=grouping_key).first()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
lowered = title.lower()
|
||||||
|
if ExtensionCandidate.objects.filter(project=project, title__iexact=title).exists() or RoadmapItem.objects.filter(project=project, title__iexact=title).exists():
|
||||||
|
return ExplorationOpportunity.objects.filter(project=project, title__iexact=title).first()
|
||||||
|
if any(item in lowered for item in ["authentication", "auth"]) and Decision.objects.filter(project=project, decision__icontains="authentication").exists():
|
||||||
|
return ExplorationOpportunity.objects.filter(project=project, title__icontains="auth").first()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _grouping_key(self, project: Project, title: str, opportunity_type: str) -> str:
|
||||||
|
fingerprint = hashlib.sha256(f"{title.lower()}:{opportunity_type.lower()}".encode("utf-8")).hexdigest()[:16]
|
||||||
|
return f"{project.id}:{opportunity_type}:{fingerprint}"[:240]
|
||||||
|
|
||||||
|
|
||||||
|
class LifecycleInspectionService:
|
||||||
|
def project_lifecycle_view(self, project: Project) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"project_id": str(project.id),
|
||||||
|
"repairs": list(project.steward_findings.filter(recommended_action="REPAIR").values("id", "title", "status", "severity")),
|
||||||
|
"extensions": [self._extension(candidate) for candidate in project.extension_candidates.order_by("-created_at")],
|
||||||
|
"evolutions": [self._evolution(candidate) for candidate in project.evolution_candidates.order_by("-created_at")],
|
||||||
|
"explorations": [self._exploration(exploration) for exploration in project.explorations.order_by("-created_at")],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _extension(self, candidate: ExtensionCandidate) -> dict[str, object]:
|
||||||
|
plan = candidate.plans.order_by("-created_at").first()
|
||||||
|
tasks = Task.objects.filter(milestone__plan=plan.project_plan) if plan and plan.project_plan_id else Task.objects.none()
|
||||||
|
return {"candidate": {"id": str(candidate.id), "title": candidate.title, "status": candidate.status}, "plan": str(plan.id) if plan else None, "tasks": list(tasks.values("id", "status", "goal")), "verification": str(plan.verification_id) if plan and plan.verification_id else None, "commits": list(candidate.commits.values("id", "sha", "task_id"))}
|
||||||
|
|
||||||
|
def _evolution(self, candidate: EvolutionCandidate) -> dict[str, object]:
|
||||||
|
plan = candidate.plans.order_by("-created_at").first()
|
||||||
|
return {"candidate": {"id": str(candidate.id), "target": candidate.target, "objective": candidate.objective, "status": candidate.status}, "baseline": candidate.baseline_measurement, "target": candidate.target_measurement, "measurement": plan.candidate_measurement if plan else {}, "delta": plan.delta if plan else {}, "verdict": plan.verdict if plan else ""}
|
||||||
|
|
||||||
|
def _exploration(self, exploration: Exploration) -> dict[str, object]:
|
||||||
|
return {"id": str(exploration.id), "status": exploration.status, "opportunities": list(exploration.opportunities.order_by("-composite_score").values("id", "title", "opportunity_type", "status", "recommended_action", "value_score", "effort_score", "risk_score", "confidence", "technical_fit", "strategic_fit", "composite_score"))}
|
||||||
|
|
||||||
|
|
||||||
|
def create_project_planning_signal(project: Project, summary: str, evidence: dict[str, object]) -> ProgenySignal:
|
||||||
|
return ProgenySignal.objects.create(project=project, source="project_lifecycle", severity="MEDIUM", failure_category="PROJECT_PLANNING", summary=summary, evidence=evidence, grouping_key=f"project_lifecycle:{project.id}:planning"[:120])
|
||||||
|
|
@ -9,8 +9,9 @@ from django.core.exceptions import ValidationError
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from agents.lifecycle import EvolutionService, ExtensionService
|
||||||
from agents.progeny import ProgenyService
|
from agents.progeny import ProgenyService
|
||||||
from control_plane.agents.models import ImprovementCandidate, ProgenySignal
|
from control_plane.agents.models import ProgenySignal
|
||||||
from control_plane.events.bus import EventBus
|
from control_plane.events.bus import EventBus
|
||||||
from control_plane.events.models import Event
|
from control_plane.events.models import Event
|
||||||
from control_plane.projects.models import (
|
from control_plane.projects.models import (
|
||||||
|
|
@ -18,7 +19,6 @@ from control_plane.projects.models import (
|
||||||
Milestone,
|
Milestone,
|
||||||
Project,
|
Project,
|
||||||
ProjectPlan,
|
ProjectPlan,
|
||||||
RoadmapItem,
|
|
||||||
StewardAction,
|
StewardAction,
|
||||||
StewardCheck,
|
StewardCheck,
|
||||||
StewardEnrollment,
|
StewardEnrollment,
|
||||||
|
|
@ -189,11 +189,17 @@ class StewardService:
|
||||||
task = None if requires_approval else self._create_repair_task(finding)
|
task = None if requires_approval else self._create_repair_task(finding)
|
||||||
action = StewardAction.objects.create(finding=finding, action_type="REPAIR", status="APPROVAL_REQUIRED" if requires_approval else "ROUTED", task=task, requires_approval=requires_approval)
|
action = StewardAction.objects.create(finding=finding, action_type="REPAIR", status="APPROVAL_REQUIRED" if requires_approval else "ROUTED", task=task, requires_approval=requires_approval)
|
||||||
elif finding.recommended_action == "EXTEND":
|
elif finding.recommended_action == "EXTEND":
|
||||||
item = RoadmapItem.objects.create(project=finding.project, title=finding.title, description=finding.summary, source="steward")
|
candidate = ExtensionService(bus=self.bus).create_candidate(finding.project, title=finding.title, description=finding.summary, rationale="Steward classified this finding as new scope.", source="StewardFinding", expected_value=finding.summary, affected_areas=[finding.finding_type], risk=finding.severity, confidence=finding.confidence, evidence={"steward_finding_id": str(finding.id), **finding.evidence}, source_steward_finding=finding)
|
||||||
action = StewardAction.objects.create(finding=finding, action_type="EXTEND", status="APPROVAL_REQUIRED" if requires_approval else "ROUTED", roadmap_item=item, requires_approval=requires_approval)
|
action = StewardAction.objects.create(finding=finding, action_type="EXTEND", status="APPROVAL_REQUIRED" if requires_approval else "ROUTED", extension_candidate=candidate, requires_approval=requires_approval)
|
||||||
elif finding.recommended_action == "EVOLVE":
|
elif finding.recommended_action == "EVOLVE":
|
||||||
candidate = ImprovementCandidate.objects.create(target_type="PROJECT", target_id=str(finding.project_id), target_label=finding.project.name, hypothesis=finding.summary, recommended_route="EvolutionCandidate", evidence={"steward_finding_id": str(finding.id), "finding_type": finding.finding_type})
|
baseline = self._baseline_from_finding(finding)
|
||||||
action = StewardAction.objects.create(finding=finding, action_type="EVOLVE", status="APPROVAL_REQUIRED" if requires_approval else "ROUTED", improvement_candidate=candidate, requires_approval=requires_approval)
|
if baseline:
|
||||||
|
candidate = EvolutionService(bus=self.bus).create_candidate(finding.project, target=str(finding.metadata.get("component", finding.finding_type)), objective=finding.summary or finding.title, baseline_measurement=baseline, desired_direction=str(finding.evidence.get("desired_direction", "DECREASE")), rationale="Steward classified this finding as measurable project evolution.", source="StewardFinding", evidence={"steward_finding_id": str(finding.id), **finding.evidence}, risk=finding.severity, confidence=finding.confidence, source_steward_finding=finding)
|
||||||
|
action = StewardAction.objects.create(finding=finding, action_type="EVOLVE", status="APPROVAL_REQUIRED" if requires_approval else "ROUTED", evolution_candidate=candidate, requires_approval=requires_approval)
|
||||||
|
else:
|
||||||
|
signal = ProgenySignal.objects.create(project=finding.project, source="steward", severity=finding.severity, failure_category=finding.finding_type, summary="Evolution baseline missing; investigate before project evolution.", evidence={"steward_finding_id": str(finding.id), **finding.evidence}, grouping_key=f"steward:missing_baseline:{finding.grouping_key}"[:120])
|
||||||
|
investigation = ProgenyService(self.bus).create_smart_investigation(signal.grouping_key)
|
||||||
|
action = StewardAction.objects.create(finding=finding, action_type="INVESTIGATE", status="ROUTED", investigation=investigation, requires_approval=False)
|
||||||
elif finding.recommended_action == "INVESTIGATE":
|
elif finding.recommended_action == "INVESTIGATE":
|
||||||
signal = ProgenySignal.objects.create(project=finding.project, source="steward", severity=finding.severity, failure_category=finding.finding_type, summary=finding.summary, evidence={"steward_finding_id": str(finding.id), **finding.evidence}, grouping_key=f"steward:{finding.grouping_key}"[:120])
|
signal = ProgenySignal.objects.create(project=finding.project, source="steward", severity=finding.severity, failure_category=finding.finding_type, summary=finding.summary, evidence={"steward_finding_id": str(finding.id), **finding.evidence}, grouping_key=f"steward:{finding.grouping_key}"[:120])
|
||||||
investigation = ProgenyService(self.bus).create_smart_investigation(signal.grouping_key)
|
investigation = ProgenyService(self.bus).create_smart_investigation(signal.grouping_key)
|
||||||
|
|
@ -324,6 +330,17 @@ class StewardService:
|
||||||
threshold = str(policy.approval_requirements.get(finding.recommended_action, "CRITICAL"))
|
threshold = str(policy.approval_requirements.get(finding.recommended_action, "CRITICAL"))
|
||||||
return SEVERITY_RANK.get(finding.severity, 0) >= SEVERITY_RANK.get(threshold, 4)
|
return SEVERITY_RANK.get(finding.severity, 0) >= SEVERITY_RANK.get(threshold, 4)
|
||||||
|
|
||||||
|
def _baseline_from_finding(self, finding: StewardFinding) -> dict[str, object]:
|
||||||
|
evidence = finding.evidence or {}
|
||||||
|
regressions = evidence.get("regressions", [])
|
||||||
|
if isinstance(regressions, list) and regressions:
|
||||||
|
first = regressions[0]
|
||||||
|
if isinstance(first, dict):
|
||||||
|
metric = str(first.get("metric", "value"))
|
||||||
|
return {"metric": metric, metric: first.get("baseline", first.get("value", 0)), "current": first.get("current"), "delta_percent": first.get("delta_percent")}
|
||||||
|
baseline = evidence.get("baseline_measurement")
|
||||||
|
return dict(baseline) if isinstance(baseline, dict) else {}
|
||||||
|
|
||||||
def _grouping_key(self, project: Project, raw: dict[str, object]) -> str:
|
def _grouping_key(self, project: Project, raw: dict[str, object]) -> str:
|
||||||
component = str(raw.get("component", "project"))[:80]
|
component = str(raw.get("component", "project"))[:80]
|
||||||
evidence = str(raw.get("evidence", {}))[:1000]
|
evidence = str(raw.get("evidence", {}))[:1000]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("agents", "0007_replay_arena"),
|
||||||
|
("graph", "0004_unique_champion_graph_version"),
|
||||||
|
("projects", "0004_steward_v1"),
|
||||||
|
("verification", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ExtensionCandidate",
|
||||||
|
fields=[
|
||||||
|
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("title", models.CharField(max_length=255)),
|
||||||
|
("description", models.TextField(blank=True)),
|
||||||
|
("rationale", models.TextField(blank=True)),
|
||||||
|
("source", models.CharField(default="user", max_length=80)),
|
||||||
|
("expected_value", models.TextField(blank=True)),
|
||||||
|
("affected_areas", models.JSONField(blank=True, default=list)),
|
||||||
|
("estimated_complexity", models.CharField(blank=True, max_length=80)),
|
||||||
|
("risk", models.CharField(default="MEDIUM", max_length=80)),
|
||||||
|
("confidence", models.FloatField(default=0.0)),
|
||||||
|
("status", models.CharField(default="PROPOSED", max_length=32)),
|
||||||
|
("evidence", models.JSONField(blank=True, default=dict)),
|
||||||
|
("metadata", models.JSONField(blank=True, default=dict)),
|
||||||
|
("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="extension_candidates", to="projects.project")),
|
||||||
|
("source_investigation", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="extension_candidates", to="agents.progenyinvestigation")),
|
||||||
|
("source_roadmap_item", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="extension_candidates", to="projects.roadmapitem")),
|
||||||
|
("source_steward_finding", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="extension_candidates", to="projects.stewardfinding")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="EvolutionCandidate",
|
||||||
|
fields=[
|
||||||
|
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("target", models.CharField(max_length=240)),
|
||||||
|
("objective", models.TextField()),
|
||||||
|
("baseline_measurement", models.JSONField(blank=True, default=dict)),
|
||||||
|
("desired_direction", models.CharField(max_length=32)),
|
||||||
|
("target_measurement", models.JSONField(blank=True, default=dict)),
|
||||||
|
("rationale", models.TextField(blank=True)),
|
||||||
|
("source", models.CharField(default="user", max_length=80)),
|
||||||
|
("evidence", models.JSONField(blank=True, default=dict)),
|
||||||
|
("status", models.CharField(default="PROPOSED", max_length=32)),
|
||||||
|
("risk", models.CharField(default="MEDIUM", max_length=80)),
|
||||||
|
("confidence", models.FloatField(default=0.0)),
|
||||||
|
("metadata", models.JSONField(blank=True, default=dict)),
|
||||||
|
("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="evolution_candidates", to="projects.project")),
|
||||||
|
("source_investigation", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="evolution_candidates", to="agents.progenyinvestigation")),
|
||||||
|
("source_steward_finding", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="evolution_candidates", to="projects.stewardfinding")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Exploration",
|
||||||
|
fields=[
|
||||||
|
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("source", models.CharField(default="Explorer", max_length=80)),
|
||||||
|
("status", models.CharField(default="RUNNING", max_length=32)),
|
||||||
|
("prompt", models.TextField(blank=True)),
|
||||||
|
("context_snapshot", models.JSONField(blank=True, default=dict)),
|
||||||
|
("completed_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("metadata", models.JSONField(blank=True, default=dict)),
|
||||||
|
("execution_graph_version", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="explorations", to="graph.executiongraphversion")),
|
||||||
|
("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="explorations", to="projects.project")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ExtensionPlan",
|
||||||
|
fields=[
|
||||||
|
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("status", models.CharField(default="DRAFT", max_length=32)),
|
||||||
|
("strategy", models.TextField(blank=True)),
|
||||||
|
("plan", models.JSONField(blank=True, default=dict)),
|
||||||
|
("acceptance_criteria", models.JSONField(blank=True, default=list)),
|
||||||
|
("context_snapshot", models.JSONField(blank=True, default=dict)),
|
||||||
|
("approved_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("completed_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("metadata", models.JSONField(blank=True, default=dict)),
|
||||||
|
("candidate", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="plans", to="projects.extensioncandidate")),
|
||||||
|
("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="extension_plans", to="projects.project")),
|
||||||
|
("project_plan", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="extension_plans", to="projects.projectplan")),
|
||||||
|
("verification", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="extension_plans", to="verification.verification")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="EvolutionPlan",
|
||||||
|
fields=[
|
||||||
|
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("status", models.CharField(default="DRAFT", max_length=32)),
|
||||||
|
("baseline", models.JSONField(blank=True, default=dict)),
|
||||||
|
("hypothesis", models.TextField(blank=True)),
|
||||||
|
("intervention", models.TextField(blank=True)),
|
||||||
|
("measurement_method", models.JSONField(blank=True, default=dict)),
|
||||||
|
("success_threshold", models.JSONField(blank=True, default=dict)),
|
||||||
|
("regression_constraints", models.JSONField(blank=True, default=list)),
|
||||||
|
("affected_components", models.JSONField(blank=True, default=list)),
|
||||||
|
("task_plan", models.JSONField(blank=True, default=dict)),
|
||||||
|
("experiment_requirements", models.JSONField(blank=True, default=dict)),
|
||||||
|
("candidate_measurement", models.JSONField(blank=True, default=dict)),
|
||||||
|
("delta", models.JSONField(blank=True, default=dict)),
|
||||||
|
("verdict", models.CharField(blank=True, max_length=32)),
|
||||||
|
("context_snapshot", models.JSONField(blank=True, default=dict)),
|
||||||
|
("approved_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("completed_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("metadata", models.JSONField(blank=True, default=dict)),
|
||||||
|
("candidate", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="plans", to="projects.evolutioncandidate")),
|
||||||
|
("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="evolution_plans", to="projects.project")),
|
||||||
|
("project_plan", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="evolution_plans", to="projects.projectplan")),
|
||||||
|
("verification", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="evolution_plans", to="verification.verification")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ExplorationOpportunity",
|
||||||
|
fields=[
|
||||||
|
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("title", models.CharField(max_length=255)),
|
||||||
|
("description", models.TextField(blank=True)),
|
||||||
|
("opportunity_type", models.CharField(max_length=80)),
|
||||||
|
("evidence", models.JSONField(blank=True, default=dict)),
|
||||||
|
("rationale", models.TextField(blank=True)),
|
||||||
|
("expected_value", models.TextField(blank=True)),
|
||||||
|
("effort_estimate", models.CharField(blank=True, max_length=80)),
|
||||||
|
("risk", models.CharField(default="MEDIUM", max_length=80)),
|
||||||
|
("confidence", models.FloatField(default=0.0)),
|
||||||
|
("technical_fit", models.FloatField(default=0.0)),
|
||||||
|
("strategic_fit", models.FloatField(default=0.0)),
|
||||||
|
("value_score", models.FloatField(default=0.0)),
|
||||||
|
("effort_score", models.FloatField(default=0.0)),
|
||||||
|
("risk_score", models.FloatField(default=0.0)),
|
||||||
|
("composite_score", models.FloatField(default=0.0)),
|
||||||
|
("recommended_action", models.CharField(default="DEFER", max_length=32)),
|
||||||
|
("status", models.CharField(default="DISCOVERED", max_length=32)),
|
||||||
|
("grouping_key", models.CharField(max_length=240)),
|
||||||
|
("metadata", models.JSONField(blank=True, default=dict)),
|
||||||
|
("converted_evolution", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="converted_from_opportunities", to="projects.evolutioncandidate")),
|
||||||
|
("converted_extension", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="converted_from_opportunities", to="projects.extensioncandidate")),
|
||||||
|
("exploration", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="opportunities", to="projects.exploration")),
|
||||||
|
("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="exploration_opportunities", to="projects.project")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(model_name="evolutioncandidate", name="source_opportunity", field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="evolution_candidates", to="projects.explorationopportunity")),
|
||||||
|
migrations.AddField(model_name="extensioncandidate", name="source_opportunity", field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="extension_candidates", to="projects.explorationopportunity")),
|
||||||
|
migrations.AddField(model_name="stewardaction", name="evolution_candidate", field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="steward_actions", to="projects.evolutioncandidate")),
|
||||||
|
migrations.AddField(model_name="stewardaction", name="extension_candidate", field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="steward_actions", to="projects.extensioncandidate")),
|
||||||
|
migrations.AddField(model_name="commitrecord", name="evolution_candidate", field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="commits", to="projects.evolutioncandidate")),
|
||||||
|
migrations.AddField(model_name="commitrecord", name="extension_candidate", field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="commits", to="projects.extensioncandidate")),
|
||||||
|
]
|
||||||
|
|
@ -179,6 +179,12 @@ class CommitRecord(TimestampedModel):
|
||||||
steward_finding = models.ForeignKey(
|
steward_finding = models.ForeignKey(
|
||||||
"projects.StewardFinding", on_delete=models.SET_NULL, null=True, blank=True, related_name="commits"
|
"projects.StewardFinding", on_delete=models.SET_NULL, null=True, blank=True, related_name="commits"
|
||||||
)
|
)
|
||||||
|
extension_candidate = models.ForeignKey(
|
||||||
|
"projects.ExtensionCandidate", on_delete=models.SET_NULL, null=True, blank=True, related_name="commits"
|
||||||
|
)
|
||||||
|
evolution_candidate = models.ForeignKey(
|
||||||
|
"projects.EvolutionCandidate", on_delete=models.SET_NULL, null=True, blank=True, related_name="commits"
|
||||||
|
)
|
||||||
sha = models.CharField(max_length=64)
|
sha = models.CharField(max_length=64)
|
||||||
branch_name = models.CharField(max_length=255)
|
branch_name = models.CharField(max_length=255)
|
||||||
message = models.TextField()
|
message = models.TextField()
|
||||||
|
|
@ -328,6 +334,123 @@ class StewardAction(TimestampedModel):
|
||||||
roadmap_item = models.ForeignKey(RoadmapItem, on_delete=models.SET_NULL, null=True, blank=True, related_name="steward_actions")
|
roadmap_item = models.ForeignKey(RoadmapItem, on_delete=models.SET_NULL, null=True, blank=True, related_name="steward_actions")
|
||||||
investigation = models.ForeignKey("agents.ProgenyInvestigation", on_delete=models.SET_NULL, null=True, blank=True, related_name="steward_actions")
|
investigation = models.ForeignKey("agents.ProgenyInvestigation", on_delete=models.SET_NULL, null=True, blank=True, related_name="steward_actions")
|
||||||
improvement_candidate = models.ForeignKey("agents.ImprovementCandidate", on_delete=models.SET_NULL, null=True, blank=True, related_name="steward_actions")
|
improvement_candidate = models.ForeignKey("agents.ImprovementCandidate", on_delete=models.SET_NULL, null=True, blank=True, related_name="steward_actions")
|
||||||
|
extension_candidate = models.ForeignKey("projects.ExtensionCandidate", on_delete=models.SET_NULL, null=True, blank=True, related_name="steward_actions")
|
||||||
|
evolution_candidate = models.ForeignKey("projects.EvolutionCandidate", on_delete=models.SET_NULL, null=True, blank=True, related_name="steward_actions")
|
||||||
requires_approval = models.BooleanField(default=False)
|
requires_approval = models.BooleanField(default=False)
|
||||||
approved_at = models.DateTimeField(null=True, blank=True)
|
approved_at = models.DateTimeField(null=True, blank=True)
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionCandidate(TimestampedModel):
|
||||||
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="extension_candidates")
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
rationale = models.TextField(blank=True)
|
||||||
|
source = models.CharField(max_length=80, default="user")
|
||||||
|
expected_value = models.TextField(blank=True)
|
||||||
|
affected_areas = models.JSONField(default=list, blank=True)
|
||||||
|
estimated_complexity = models.CharField(max_length=80, blank=True)
|
||||||
|
risk = models.CharField(max_length=80, default="MEDIUM")
|
||||||
|
confidence = models.FloatField(default=0.0)
|
||||||
|
status = models.CharField(max_length=32, default="PROPOSED")
|
||||||
|
evidence = models.JSONField(default=dict, blank=True)
|
||||||
|
source_steward_finding = models.ForeignKey(StewardFinding, on_delete=models.SET_NULL, null=True, blank=True, related_name="extension_candidates")
|
||||||
|
source_investigation = models.ForeignKey("agents.ProgenyInvestigation", on_delete=models.SET_NULL, null=True, blank=True, related_name="extension_candidates")
|
||||||
|
source_roadmap_item = models.ForeignKey(RoadmapItem, on_delete=models.SET_NULL, null=True, blank=True, related_name="extension_candidates")
|
||||||
|
source_opportunity = models.ForeignKey("projects.ExplorationOpportunity", on_delete=models.SET_NULL, null=True, blank=True, related_name="extension_candidates")
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionPlan(TimestampedModel):
|
||||||
|
candidate = models.ForeignKey(ExtensionCandidate, on_delete=models.CASCADE, related_name="plans")
|
||||||
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="extension_plans")
|
||||||
|
status = models.CharField(max_length=32, default="DRAFT")
|
||||||
|
strategy = models.TextField(blank=True)
|
||||||
|
plan = models.JSONField(default=dict, blank=True)
|
||||||
|
acceptance_criteria = models.JSONField(default=list, blank=True)
|
||||||
|
context_snapshot = models.JSONField(default=dict, blank=True)
|
||||||
|
project_plan = models.ForeignKey(ProjectPlan, on_delete=models.SET_NULL, null=True, blank=True, related_name="extension_plans")
|
||||||
|
verification = models.ForeignKey("verification.Verification", on_delete=models.SET_NULL, null=True, blank=True, related_name="extension_plans")
|
||||||
|
approved_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
completed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class EvolutionCandidate(TimestampedModel):
|
||||||
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="evolution_candidates")
|
||||||
|
target = models.CharField(max_length=240)
|
||||||
|
objective = models.TextField()
|
||||||
|
baseline_measurement = models.JSONField(default=dict, blank=True)
|
||||||
|
desired_direction = models.CharField(max_length=32)
|
||||||
|
target_measurement = models.JSONField(default=dict, blank=True)
|
||||||
|
rationale = models.TextField(blank=True)
|
||||||
|
source = models.CharField(max_length=80, default="user")
|
||||||
|
evidence = models.JSONField(default=dict, blank=True)
|
||||||
|
status = models.CharField(max_length=32, default="PROPOSED")
|
||||||
|
risk = models.CharField(max_length=80, default="MEDIUM")
|
||||||
|
confidence = models.FloatField(default=0.0)
|
||||||
|
source_steward_finding = models.ForeignKey(StewardFinding, on_delete=models.SET_NULL, null=True, blank=True, related_name="evolution_candidates")
|
||||||
|
source_investigation = models.ForeignKey("agents.ProgenyInvestigation", on_delete=models.SET_NULL, null=True, blank=True, related_name="evolution_candidates")
|
||||||
|
source_opportunity = models.ForeignKey("projects.ExplorationOpportunity", on_delete=models.SET_NULL, null=True, blank=True, related_name="evolution_candidates")
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class EvolutionPlan(TimestampedModel):
|
||||||
|
candidate = models.ForeignKey(EvolutionCandidate, on_delete=models.CASCADE, related_name="plans")
|
||||||
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="evolution_plans")
|
||||||
|
status = models.CharField(max_length=32, default="DRAFT")
|
||||||
|
baseline = models.JSONField(default=dict, blank=True)
|
||||||
|
hypothesis = models.TextField(blank=True)
|
||||||
|
intervention = models.TextField(blank=True)
|
||||||
|
measurement_method = models.JSONField(default=dict, blank=True)
|
||||||
|
success_threshold = models.JSONField(default=dict, blank=True)
|
||||||
|
regression_constraints = models.JSONField(default=list, blank=True)
|
||||||
|
affected_components = models.JSONField(default=list, blank=True)
|
||||||
|
task_plan = models.JSONField(default=dict, blank=True)
|
||||||
|
experiment_requirements = models.JSONField(default=dict, blank=True)
|
||||||
|
candidate_measurement = models.JSONField(default=dict, blank=True)
|
||||||
|
delta = models.JSONField(default=dict, blank=True)
|
||||||
|
verdict = models.CharField(max_length=32, blank=True)
|
||||||
|
context_snapshot = models.JSONField(default=dict, blank=True)
|
||||||
|
project_plan = models.ForeignKey(ProjectPlan, on_delete=models.SET_NULL, null=True, blank=True, related_name="evolution_plans")
|
||||||
|
verification = models.ForeignKey("verification.Verification", on_delete=models.SET_NULL, null=True, blank=True, related_name="evolution_plans")
|
||||||
|
approved_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
completed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Exploration(TimestampedModel):
|
||||||
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="explorations")
|
||||||
|
source = models.CharField(max_length=80, default="Explorer")
|
||||||
|
status = models.CharField(max_length=32, default="RUNNING")
|
||||||
|
prompt = models.TextField(blank=True)
|
||||||
|
context_snapshot = models.JSONField(default=dict, blank=True)
|
||||||
|
execution_graph_version = models.ForeignKey("graph.ExecutionGraphVersion", on_delete=models.SET_NULL, null=True, blank=True, related_name="explorations")
|
||||||
|
completed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ExplorationOpportunity(TimestampedModel):
|
||||||
|
exploration = models.ForeignKey(Exploration, on_delete=models.CASCADE, related_name="opportunities")
|
||||||
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="exploration_opportunities")
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
opportunity_type = models.CharField(max_length=80)
|
||||||
|
evidence = models.JSONField(default=dict, blank=True)
|
||||||
|
rationale = models.TextField(blank=True)
|
||||||
|
expected_value = models.TextField(blank=True)
|
||||||
|
effort_estimate = models.CharField(max_length=80, blank=True)
|
||||||
|
risk = models.CharField(max_length=80, default="MEDIUM")
|
||||||
|
confidence = models.FloatField(default=0.0)
|
||||||
|
technical_fit = models.FloatField(default=0.0)
|
||||||
|
strategic_fit = models.FloatField(default=0.0)
|
||||||
|
value_score = models.FloatField(default=0.0)
|
||||||
|
effort_score = models.FloatField(default=0.0)
|
||||||
|
risk_score = models.FloatField(default=0.0)
|
||||||
|
composite_score = models.FloatField(default=0.0)
|
||||||
|
recommended_action = models.CharField(max_length=32, default="DEFER")
|
||||||
|
status = models.CharField(max_length=32, default="DISCOVERED")
|
||||||
|
grouping_key = models.CharField(max_length=240)
|
||||||
|
converted_extension = models.ForeignKey(ExtensionCandidate, on_delete=models.SET_NULL, null=True, blank=True, related_name="converted_from_opportunities")
|
||||||
|
converted_evolution = models.ForeignKey(EvolutionCandidate, on_delete=models.SET_NULL, null=True, blank=True, related_name="converted_from_opportunities")
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from graph.lifecycle import project_evolution_graph_v1, project_exploration_graph_v1, project_extension_graph_v1
|
||||||
from graph.models import ExecutionGraphDefinition, ExecutionGraphVersion, ExecutionGraphVersionStatus
|
from graph.models import ExecutionGraphDefinition, ExecutionGraphVersion, ExecutionGraphVersionStatus
|
||||||
from graph.steward import steward_run_graph_v1
|
from graph.steward import steward_run_graph_v1
|
||||||
from graph.task_execution import task_execution_graph_v1
|
from graph.task_execution import task_execution_graph_v1
|
||||||
|
|
@ -51,3 +52,32 @@ def champion_steward_run_graph_v1() -> ExecutionGraphVersion:
|
||||||
version.promoted_at = timezone.now()
|
version.promoted_at = timezone.now()
|
||||||
version.save(update_fields=["status", "promoted_at"])
|
version.save(update_fields=["status", "promoted_at"])
|
||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def _champion_graph(spec) -> ExecutionGraphVersion:
|
||||||
|
definition, _ = ExecutionGraphDefinition.objects.get_or_create(
|
||||||
|
name=spec.name,
|
||||||
|
defaults={"graph_type": spec.graph_type, "description": str(spec.metadata.get("description", ""))},
|
||||||
|
)
|
||||||
|
version, created = ExecutionGraphVersion.objects.get_or_create(
|
||||||
|
graph=definition,
|
||||||
|
version=spec.version,
|
||||||
|
defaults={"status": ExecutionGraphVersionStatus.CHAMPION, "graph_spec": spec.to_dict(), "metadata": {"immutable_after_use": True}, "promoted_at": timezone.now()},
|
||||||
|
)
|
||||||
|
if not created and version.status != ExecutionGraphVersionStatus.CHAMPION:
|
||||||
|
version.status = ExecutionGraphVersionStatus.CHAMPION
|
||||||
|
version.promoted_at = timezone.now()
|
||||||
|
version.save(update_fields=["status", "promoted_at"])
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def champion_project_extension_graph_v1() -> ExecutionGraphVersion:
|
||||||
|
return _champion_graph(project_extension_graph_v1())
|
||||||
|
|
||||||
|
|
||||||
|
def champion_project_evolution_graph_v1() -> ExecutionGraphVersion:
|
||||||
|
return _champion_graph(project_evolution_graph_v1())
|
||||||
|
|
||||||
|
|
||||||
|
def champion_project_exploration_graph_v1() -> ExecutionGraphVersion:
|
||||||
|
return _champion_graph(project_exploration_graph_v1())
|
||||||
|
|
|
||||||
329
graph/lifecycle.py
Normal file
329
graph/lifecycle.py
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from agents.lifecycle import EvolutionService, ExplorerService, ExtensionService
|
||||||
|
from graph.models import GraphApproval, GraphApprovalStatus
|
||||||
|
from graph.native_runtime import GraphExecutionContext
|
||||||
|
from graph.registry import NodeHandlerRegistry, NodeResult
|
||||||
|
from graph.spec import ExecutionGraphSpec, GraphEdgeSpec, GraphNodeSpec
|
||||||
|
|
||||||
|
|
||||||
|
def project_extension_graph_v1() -> ExecutionGraphSpec:
|
||||||
|
spec = ExecutionGraphSpec(
|
||||||
|
name="project_extension",
|
||||||
|
version=1,
|
||||||
|
graph_type="PROJECT_EXTENSION",
|
||||||
|
entry="prepare",
|
||||||
|
nodes={node: GraphNodeSpec(node, node if node == "complete" else f"extension_{node}") for node in ["prepare", "gather_context", "plan_with_project_brain", "validate_plan", "await_approval", "materialize_project_dag", "execute", "verify_extension", "complete"]},
|
||||||
|
edges=[GraphEdgeSpec("prepare", "gather_context", "success"), GraphEdgeSpec("gather_context", "plan_with_project_brain", "success"), GraphEdgeSpec("plan_with_project_brain", "validate_plan", "success"), GraphEdgeSpec("validate_plan", "await_approval", "success"), GraphEdgeSpec("await_approval", "materialize_project_dag", "approved"), GraphEdgeSpec("await_approval", "complete", "rejected"), GraphEdgeSpec("materialize_project_dag", "execute", "success"), GraphEdgeSpec("execute", "verify_extension", "success"), GraphEdgeSpec("verify_extension", "complete", "success")],
|
||||||
|
terminal_nodes=["complete"],
|
||||||
|
metadata={"description": "Project extension workflow: Sol plan, approval, normal task execution, milestone verification."},
|
||||||
|
)
|
||||||
|
spec.validate()
|
||||||
|
return spec
|
||||||
|
|
||||||
|
|
||||||
|
def project_evolution_graph_v1() -> ExecutionGraphSpec:
|
||||||
|
spec = ExecutionGraphSpec(
|
||||||
|
name="project_evolution",
|
||||||
|
version=1,
|
||||||
|
graph_type="PROJECT_EVOLUTION",
|
||||||
|
entry="prepare",
|
||||||
|
nodes={node: GraphNodeSpec(node, node if node in ["complete", "not_improved"] else f"evolution_{node}") for node in ["prepare", "establish_baseline", "form_hypothesis", "plan_with_project_brain", "await_approval", "materialize_work", "execute", "measure_candidate", "compare_baseline", "judge_evolution", "complete", "not_improved"]},
|
||||||
|
edges=[GraphEdgeSpec("prepare", "establish_baseline", "success"), GraphEdgeSpec("establish_baseline", "form_hypothesis", "success"), GraphEdgeSpec("form_hypothesis", "plan_with_project_brain", "success"), GraphEdgeSpec("plan_with_project_brain", "await_approval", "success"), GraphEdgeSpec("await_approval", "materialize_work", "approved"), GraphEdgeSpec("await_approval", "not_improved", "rejected"), GraphEdgeSpec("materialize_work", "execute", "success"), GraphEdgeSpec("execute", "measure_candidate", "success"), GraphEdgeSpec("measure_candidate", "compare_baseline", "success"), GraphEdgeSpec("compare_baseline", "judge_evolution", "success"), GraphEdgeSpec("judge_evolution", "complete", "PASS"), GraphEdgeSpec("judge_evolution", "not_improved", "NOT_IMPROVED")],
|
||||||
|
terminal_nodes=["complete", "not_improved"],
|
||||||
|
metadata={"description": "Project evolution workflow: measurable baseline, Sol plan, approval, normal task execution, objective comparison."},
|
||||||
|
)
|
||||||
|
spec.validate()
|
||||||
|
return spec
|
||||||
|
|
||||||
|
|
||||||
|
def project_exploration_graph_v1() -> ExecutionGraphSpec:
|
||||||
|
spec = ExecutionGraphSpec(
|
||||||
|
name="project_exploration",
|
||||||
|
version=1,
|
||||||
|
graph_type="PROJECT_EXPLORATION",
|
||||||
|
entry="prepare",
|
||||||
|
nodes={node: GraphNodeSpec(node, node if node == "complete" else f"exploration_{node}") for node in ["prepare", "gather_project_evidence", "identify_gaps", "generate_opportunities", "normalize", "deduplicate", "score", "rank", "complete"]},
|
||||||
|
edges=[GraphEdgeSpec("prepare", "gather_project_evidence", "success"), GraphEdgeSpec("gather_project_evidence", "identify_gaps", "success"), GraphEdgeSpec("identify_gaps", "generate_opportunities", "success"), GraphEdgeSpec("generate_opportunities", "normalize", "success"), GraphEdgeSpec("normalize", "deduplicate", "success"), GraphEdgeSpec("deduplicate", "score", "success"), GraphEdgeSpec("score", "rank", "success"), GraphEdgeSpec("rank", "complete", "success")],
|
||||||
|
terminal_nodes=["complete"],
|
||||||
|
metadata={"description": "Project exploration workflow: discover, deduplicate, score, rank opportunities without execution."},
|
||||||
|
)
|
||||||
|
spec.validate()
|
||||||
|
return spec
|
||||||
|
|
||||||
|
|
||||||
|
class LifecycleNode:
|
||||||
|
idempotent = True
|
||||||
|
replay_safe = True
|
||||||
|
destructive = False
|
||||||
|
|
||||||
|
def __init__(self, node_type: str) -> None:
|
||||||
|
self.node_type = node_type
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionNode(LifecycleNode):
|
||||||
|
def __init__(self, service: ExtensionService, node_type: str) -> None:
|
||||||
|
super().__init__(node_type)
|
||||||
|
self.service = service
|
||||||
|
|
||||||
|
def plan_id(self, context: GraphExecutionContext) -> str:
|
||||||
|
return str(context.graph_run.metadata.get("extension_plan_id", ""))
|
||||||
|
|
||||||
|
def candidate_id(self, context: GraphExecutionContext) -> str:
|
||||||
|
return str(context.graph_run.metadata["extension_candidate_id"])
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionPrepareNode(ExtensionNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
return NodeResult("COMPLETE", "success")
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionGatherContextNode(ExtensionNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
from control_plane.projects.models import ExtensionCandidate
|
||||||
|
|
||||||
|
candidate = ExtensionCandidate.objects.get(id=self.candidate_id(context))
|
||||||
|
metadata = dict(context.graph_run.metadata)
|
||||||
|
metadata["context_snapshot"] = self.service.project_context(candidate.project)
|
||||||
|
context.graph_run.metadata = metadata
|
||||||
|
context.graph_run.save(update_fields=["metadata", "updated_at"])
|
||||||
|
return NodeResult("COMPLETE", "success")
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionPlanNode(ExtensionNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
from control_plane.projects.models import ExtensionCandidate
|
||||||
|
|
||||||
|
candidate = ExtensionCandidate.objects.get(id=self.candidate_id(context))
|
||||||
|
plan = candidate.plans.order_by("-created_at").first() or self.service.plan_with_project_brain(candidate)
|
||||||
|
metadata = dict(context.graph_run.metadata)
|
||||||
|
metadata["extension_plan_id"] = str(plan.id)
|
||||||
|
context.graph_run.metadata = metadata
|
||||||
|
context.graph_run.save(update_fields=["metadata", "updated_at"])
|
||||||
|
return NodeResult("COMPLETE", "success", {"extension_plan_id": str(plan.id)})
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionValidatePlanNode(ExtensionNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
from control_plane.projects.models import ExtensionPlan
|
||||||
|
from project_brain.planning import parse_project_plan_response
|
||||||
|
import json
|
||||||
|
|
||||||
|
plan = ExtensionPlan.objects.get(id=self.plan_id(context))
|
||||||
|
parse_project_plan_response(json.dumps(plan.plan.get("project_plan", {})))
|
||||||
|
return NodeResult("COMPLETE", "success")
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalNode(LifecycleNode):
|
||||||
|
reason = "AWAITING_LIFECYCLE_APPROVAL"
|
||||||
|
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
node_run = context.graph_run.node_runs.filter(node_id=context.graph_run.current_node).order_by("-visit_index").first()
|
||||||
|
if GraphApproval.objects.filter(graph_run=context.graph_run, status=GraphApprovalStatus.APPROVED).exists():
|
||||||
|
return NodeResult("COMPLETE", "approved")
|
||||||
|
if GraphApproval.objects.filter(graph_run=context.graph_run, status=GraphApprovalStatus.REJECTED).exists():
|
||||||
|
return NodeResult("COMPLETE", "rejected")
|
||||||
|
GraphApproval.objects.get_or_create(graph_run=context.graph_run, node_run=node_run, reason=self.reason)
|
||||||
|
return NodeResult("PAUSED", "awaiting", pause_reason=self.reason)
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionMaterializeNode(ExtensionNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
from control_plane.projects.models import ExtensionPlan
|
||||||
|
|
||||||
|
plan = ExtensionPlan.objects.get(id=self.plan_id(context))
|
||||||
|
self.service.approve_plan(plan)
|
||||||
|
project_plan = self.service.materialize_project_dag(plan)
|
||||||
|
return NodeResult("COMPLETE", "success", {"project_plan_id": str(project_plan.id)})
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionExecuteNode(ExtensionNode):
|
||||||
|
destructive = True
|
||||||
|
|
||||||
|
def __init__(self, service: ExtensionService, node_type: str, router=None, test_command: list[str] | None = None) -> None:
|
||||||
|
super().__init__(service, node_type)
|
||||||
|
self.router = router
|
||||||
|
self.test_command = test_command
|
||||||
|
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
from control_plane.projects.models import ExtensionPlan
|
||||||
|
|
||||||
|
if self.router is None:
|
||||||
|
return NodeResult("COMPLETE", "success", {"executed": False})
|
||||||
|
plan = ExtensionPlan.objects.get(id=self.plan_id(context))
|
||||||
|
runs = self.service.execute(plan, self.router, test_command=self.test_command)
|
||||||
|
return NodeResult("COMPLETE", "success", {"graph_run_ids": [str(run.id) for run in runs]})
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionVerifyNode(ExtensionNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
from control_plane.projects.models import ExtensionPlan
|
||||||
|
|
||||||
|
plan = ExtensionPlan.objects.get(id=self.plan_id(context))
|
||||||
|
verification = self.service.verify_extension(plan)
|
||||||
|
return NodeResult("COMPLETE", "success", {"verification_id": str(verification.id), "result": verification.result})
|
||||||
|
|
||||||
|
|
||||||
|
class EvolutionNode(LifecycleNode):
|
||||||
|
def __init__(self, service: EvolutionService, node_type: str) -> None:
|
||||||
|
super().__init__(node_type)
|
||||||
|
self.service = service
|
||||||
|
|
||||||
|
def candidate_id(self, context: GraphExecutionContext) -> str:
|
||||||
|
return str(context.graph_run.metadata["evolution_candidate_id"])
|
||||||
|
|
||||||
|
def plan_id(self, context: GraphExecutionContext) -> str:
|
||||||
|
return str(context.graph_run.metadata.get("evolution_plan_id", ""))
|
||||||
|
|
||||||
|
|
||||||
|
class EvolutionSimpleNode(EvolutionNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
return NodeResult("COMPLETE", "success")
|
||||||
|
|
||||||
|
|
||||||
|
class EvolutionBaselineNode(EvolutionNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
from control_plane.projects.models import EvolutionCandidate
|
||||||
|
|
||||||
|
candidate = EvolutionCandidate.objects.get(id=self.candidate_id(context))
|
||||||
|
if not candidate.baseline_measurement:
|
||||||
|
return NodeResult("FAILED", "failure", failure_evidence={"reason": "EVOLVE requires baseline measurement"})
|
||||||
|
return NodeResult("COMPLETE", "success", {"baseline": candidate.baseline_measurement})
|
||||||
|
|
||||||
|
|
||||||
|
class EvolutionPlanNode(EvolutionNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
from control_plane.projects.models import EvolutionCandidate
|
||||||
|
|
||||||
|
candidate = EvolutionCandidate.objects.get(id=self.candidate_id(context))
|
||||||
|
plan = candidate.plans.order_by("-created_at").first() or self.service.plan_with_project_brain(candidate)
|
||||||
|
metadata = dict(context.graph_run.metadata)
|
||||||
|
metadata["evolution_plan_id"] = str(plan.id)
|
||||||
|
context.graph_run.metadata = metadata
|
||||||
|
context.graph_run.save(update_fields=["metadata", "updated_at"])
|
||||||
|
return NodeResult("COMPLETE", "success", {"evolution_plan_id": str(plan.id)})
|
||||||
|
|
||||||
|
|
||||||
|
class EvolutionMaterializeNode(EvolutionNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
from control_plane.projects.models import EvolutionPlan
|
||||||
|
|
||||||
|
plan = EvolutionPlan.objects.get(id=self.plan_id(context))
|
||||||
|
self.service.approve_plan(plan)
|
||||||
|
project_plan = self.service.materialize_work(plan)
|
||||||
|
return NodeResult("COMPLETE", "success", {"project_plan_id": str(project_plan.id)})
|
||||||
|
|
||||||
|
|
||||||
|
class EvolutionExecuteNode(EvolutionNode):
|
||||||
|
destructive = True
|
||||||
|
|
||||||
|
def __init__(self, service: EvolutionService, node_type: str, router=None, test_command: list[str] | None = None) -> None:
|
||||||
|
super().__init__(service, node_type)
|
||||||
|
self.router = router
|
||||||
|
self.test_command = test_command
|
||||||
|
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
from control_plane.projects.models import EvolutionPlan
|
||||||
|
|
||||||
|
if self.router is None:
|
||||||
|
return NodeResult("COMPLETE", "success", {"executed": False})
|
||||||
|
plan = EvolutionPlan.objects.get(id=self.plan_id(context))
|
||||||
|
runs = self.service.execute(plan, self.router, test_command=self.test_command)
|
||||||
|
return NodeResult("COMPLETE", "success", {"graph_run_ids": [str(run.id) for run in runs]})
|
||||||
|
|
||||||
|
|
||||||
|
class EvolutionMeasureNode(EvolutionNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
from control_plane.projects.models import EvolutionPlan
|
||||||
|
|
||||||
|
plan = EvolutionPlan.objects.get(id=self.plan_id(context))
|
||||||
|
measurement = self.service.measure_candidate(plan)
|
||||||
|
return NodeResult("COMPLETE", "success", {"measurement": measurement})
|
||||||
|
|
||||||
|
|
||||||
|
class EvolutionCompareNode(EvolutionNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
from control_plane.projects.models import EvolutionPlan
|
||||||
|
|
||||||
|
plan = EvolutionPlan.objects.get(id=self.plan_id(context))
|
||||||
|
delta = self.service.compare_baseline(plan)
|
||||||
|
return NodeResult("COMPLETE", "success", {"delta": delta})
|
||||||
|
|
||||||
|
|
||||||
|
class EvolutionJudgeNode(EvolutionNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
from control_plane.projects.models import EvolutionPlan
|
||||||
|
|
||||||
|
plan = EvolutionPlan.objects.get(id=self.plan_id(context))
|
||||||
|
verification = self.service.judge_evolution(plan)
|
||||||
|
plan.refresh_from_db()
|
||||||
|
return NodeResult("COMPLETE", plan.verdict or "NOT_IMPROVED", {"verification_id": str(verification.id), "verdict": plan.verdict})
|
||||||
|
|
||||||
|
|
||||||
|
class ExplorationNode(LifecycleNode):
|
||||||
|
def __init__(self, service: ExplorerService, node_type: str) -> None:
|
||||||
|
super().__init__(node_type)
|
||||||
|
self.service = service
|
||||||
|
|
||||||
|
def exploration_id(self, context: GraphExecutionContext) -> str:
|
||||||
|
return str(context.graph_run.metadata["exploration_id"])
|
||||||
|
|
||||||
|
|
||||||
|
class ExplorationSimpleNode(ExplorationNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
return NodeResult("COMPLETE", "success")
|
||||||
|
|
||||||
|
|
||||||
|
class ExplorationGatherNode(ExplorationNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
from control_plane.projects.models import Exploration
|
||||||
|
|
||||||
|
exploration = Exploration.objects.get(id=self.exploration_id(context))
|
||||||
|
exploration.context_snapshot = self.service.project_context(exploration.project)
|
||||||
|
exploration.save(update_fields=["context_snapshot", "updated_at"])
|
||||||
|
return NodeResult("COMPLETE", "success")
|
||||||
|
|
||||||
|
|
||||||
|
class ExplorationGenerateNode(ExplorationNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
from control_plane.projects.models import Exploration
|
||||||
|
|
||||||
|
exploration = Exploration.objects.get(id=self.exploration_id(context))
|
||||||
|
opportunities = self.service.generate_opportunities(exploration)
|
||||||
|
return NodeResult("COMPLETE", "success", {"opportunity_count": len(opportunities)})
|
||||||
|
|
||||||
|
|
||||||
|
class ExplorationRankNode(ExplorationNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
from control_plane.projects.models import Exploration
|
||||||
|
|
||||||
|
exploration = Exploration.objects.get(id=self.exploration_id(context))
|
||||||
|
ranked = list(exploration.opportunities.order_by("-composite_score").values_list("id", flat=True))
|
||||||
|
exploration.metadata = {**exploration.metadata, "ranked_opportunity_ids": [str(item) for item in ranked]}
|
||||||
|
exploration.status = "COMPLETE"
|
||||||
|
exploration.completed_at = timezone.now()
|
||||||
|
exploration.save(update_fields=["metadata", "status", "completed_at", "updated_at"])
|
||||||
|
return NodeResult("COMPLETE", "success", {"ranked_opportunity_ids": [str(item) for item in ranked]})
|
||||||
|
|
||||||
|
|
||||||
|
def extension_registry(service: ExtensionService, router=None, test_command: list[str] | None = None) -> NodeHandlerRegistry:
|
||||||
|
registry = NodeHandlerRegistry()
|
||||||
|
for handler in [ExtensionPrepareNode(service, "extension_prepare"), ExtensionGatherContextNode(service, "extension_gather_context"), ExtensionPlanNode(service, "extension_plan_with_project_brain"), ExtensionValidatePlanNode(service, "extension_validate_plan"), ApprovalNode("extension_await_approval"), ExtensionMaterializeNode(service, "extension_materialize_project_dag"), ExtensionExecuteNode(service, "extension_execute", router, test_command), ExtensionVerifyNode(service, "extension_verify_extension")]:
|
||||||
|
registry.register(handler)
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
def evolution_registry(service: EvolutionService, router=None, test_command: list[str] | None = None) -> NodeHandlerRegistry:
|
||||||
|
registry = NodeHandlerRegistry()
|
||||||
|
for handler in [EvolutionSimpleNode(service, "evolution_prepare"), EvolutionBaselineNode(service, "evolution_establish_baseline"), EvolutionSimpleNode(service, "evolution_form_hypothesis"), EvolutionPlanNode(service, "evolution_plan_with_project_brain"), ApprovalNode("evolution_await_approval"), EvolutionMaterializeNode(service, "evolution_materialize_work"), EvolutionExecuteNode(service, "evolution_execute", router, test_command), EvolutionMeasureNode(service, "evolution_measure_candidate"), EvolutionCompareNode(service, "evolution_compare_baseline"), EvolutionJudgeNode(service, "evolution_judge_evolution")]:
|
||||||
|
registry.register(handler)
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
def exploration_registry(service: ExplorerService) -> NodeHandlerRegistry:
|
||||||
|
registry = NodeHandlerRegistry()
|
||||||
|
for handler in [ExplorationSimpleNode(service, "exploration_prepare"), ExplorationGatherNode(service, "exploration_gather_project_evidence"), ExplorationSimpleNode(service, "exploration_identify_gaps"), ExplorationGenerateNode(service, "exploration_generate_opportunities"), ExplorationSimpleNode(service, "exploration_normalize"), ExplorationSimpleNode(service, "exploration_deduplicate"), ExplorationSimpleNode(service, "exploration_score"), ExplorationRankNode(service, "exploration_rank")]:
|
||||||
|
registry.register(handler)
|
||||||
|
return registry
|
||||||
239
tests/test_extend_evolve_explore_v1.py
Normal file
239
tests/test_extend_evolve_explore_v1.py
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
from agents.lifecycle import EvolutionService, ExplorerService, ExtensionService, LifecycleInspectionService
|
||||||
|
from agents.providers import DeterministicCodingProvider, DeterministicSolProvider
|
||||||
|
from agents.steward import StewardService
|
||||||
|
from control_plane.agents.management.commands.seed_core_agents import Command as SeedAgentsCommand
|
||||||
|
from control_plane.projects.models import ExtensionCandidate, ExplorationOpportunity, EvolutionCandidate, Project, StewardFinding, StewardPolicy, Task, TaskStatus
|
||||||
|
from graph.bootstrap import champion_project_evolution_graph_v1, champion_project_exploration_graph_v1, champion_project_extension_graph_v1
|
||||||
|
from graph.langgraph_runtime import LangGraphRuntime
|
||||||
|
from graph.lifecycle import evolution_registry, exploration_registry, extension_registry, project_evolution_graph_v1, project_exploration_graph_v1, project_extension_graph_v1
|
||||||
|
from graph.models import GraphApprovalStatus, GraphRun, GraphRunStatus
|
||||||
|
from model_router.router import ModelRouter
|
||||||
|
from tests.test_m2_autonomous_loop import create_disposable_django_repo
|
||||||
|
|
||||||
|
|
||||||
|
def sol_router(payload: dict[str, object]) -> ModelRouter:
|
||||||
|
return ModelRouter({"sol": DeterministicSolProvider(json.dumps(payload))}, persist_requests=True)
|
||||||
|
|
||||||
|
|
||||||
|
def qwen_router() -> ModelRouter:
|
||||||
|
SeedAgentsCommand().handle()
|
||||||
|
return ModelRouter({"qwen": DeterministicCodingProvider()}, persist_requests=True)
|
||||||
|
|
||||||
|
|
||||||
|
def extension_payload() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"extension_plan": {
|
||||||
|
"strategy": "Add a bounded health endpoint to the existing Django app.",
|
||||||
|
"acceptance_criteria": ["/health returns ok", "tests pass"],
|
||||||
|
"project_plan": {
|
||||||
|
"goal": "Keep existing project intent while adding health endpoint.",
|
||||||
|
"scope": "Existing-project extension only.",
|
||||||
|
"acceptance_criteria": ["/health returns ok", "tests pass"],
|
||||||
|
"milestones": [
|
||||||
|
{
|
||||||
|
"key": "HEALTH",
|
||||||
|
"title": "Health Endpoint",
|
||||||
|
"goal": "Add health endpoint",
|
||||||
|
"verification_contract": {"type": "extension"},
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"key": "F1",
|
||||||
|
"title": "Health endpoint",
|
||||||
|
"description": "Expose /health.",
|
||||||
|
"acceptance_criteria": ["/health returns ok"],
|
||||||
|
"tasks": [{"id": "T1", "goal": 'Add a /health endpoint returning JSON {"status": "ok"} and add tests.', "type": "implementation", "acceptance_criteria": ["/health returns ok", "tests pass"], "priority": 50, "dependencies": []}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def evolution_payload(candidate: EvolutionCandidate, *, candidate_value: int = 95) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"evolution_plan": {
|
||||||
|
"baseline": candidate.baseline_measurement,
|
||||||
|
"hypothesis": "Health endpoint implementation will keep tests passing while we compare latency.",
|
||||||
|
"intervention": "Add small health endpoint fixture change.",
|
||||||
|
"measurement_method": {"type": "metadata"},
|
||||||
|
"success_threshold": {"metric": "duration", "minimum_improvement_percent": 10},
|
||||||
|
"regression_constraints": ["Deterministic tests pass"],
|
||||||
|
"affected_components": [candidate.target],
|
||||||
|
"candidate_measurement": {"metric": "duration", "duration": candidate_value},
|
||||||
|
"project_plan": extension_payload()["extension_plan"]["project_plan"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def project(tmp_path: Path) -> Project:
|
||||||
|
return Project.objects.create(name="Lifecycle", goal="Existing product", repository_path=str(create_disposable_django_repo(tmp_path)))
|
||||||
|
|
||||||
|
|
||||||
|
def approve_and_resume(graph_run: GraphRun, registry) -> GraphRun:
|
||||||
|
approval = graph_run.approvals.get()
|
||||||
|
approval.status = GraphApprovalStatus.APPROVED
|
||||||
|
approval.decided_by = "tester"
|
||||||
|
approval.save(update_fields=["status", "decided_by", "updated_at"])
|
||||||
|
graph_run.status = GraphRunStatus.RUNNING
|
||||||
|
graph_run.save(update_fields=["status", "updated_at"])
|
||||||
|
LangGraphRuntime(registry).run_until_terminal_or_paused(graph_run)
|
||||||
|
graph_run.refresh_from_db()
|
||||||
|
return graph_run
|
||||||
|
|
||||||
|
|
||||||
|
def test_extension_candidate_plan_graph_execution_verification_and_lineage(tmp_path: Path) -> None:
|
||||||
|
proj = project(tmp_path)
|
||||||
|
service = ExtensionService(sol_router(extension_payload()))
|
||||||
|
candidate = service.create_candidate(proj, title="Add health endpoint", description="Add /health endpoint", source="user", expected_value="Operational health checks", affected_areas=["api"], confidence=0.8)
|
||||||
|
version = champion_project_extension_graph_v1()
|
||||||
|
graph_run = GraphRun.objects.create(execution_graph_version=version, project=proj, current_node=version.graph_spec["entry"], metadata={"extension_candidate_id": str(candidate.id)})
|
||||||
|
|
||||||
|
registry = extension_registry(service, qwen_router(), test_command=["python", "manage.py", "test"])
|
||||||
|
LangGraphRuntime(registry).run_until_terminal_or_paused(graph_run)
|
||||||
|
assert graph_run.status == GraphRunStatus.PAUSED
|
||||||
|
|
||||||
|
approve_and_resume(graph_run, registry)
|
||||||
|
|
||||||
|
candidate.refresh_from_db()
|
||||||
|
plan = candidate.plans.get()
|
||||||
|
task = Task.objects.get(milestone__plan=plan.project_plan)
|
||||||
|
commit = task.commits.get()
|
||||||
|
assert graph_run.status == GraphRunStatus.COMPLETE
|
||||||
|
assert candidate.status == "COMPLETE"
|
||||||
|
assert plan.status == "COMPLETE"
|
||||||
|
assert task.status == TaskStatus.COMPLETE
|
||||||
|
assert commit.extension_candidate == candidate
|
||||||
|
assert plan.verification.result == "PASS"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extension_verification_requires_acceptance_not_just_commits(tmp_path: Path) -> None:
|
||||||
|
proj = project(tmp_path)
|
||||||
|
service = ExtensionService()
|
||||||
|
candidate = service.create_candidate(proj, title="Empty extension", description="No acceptance", source="user")
|
||||||
|
plan = service.plan_with_project_brain(candidate)
|
||||||
|
plan.acceptance_criteria = []
|
||||||
|
plan.save(update_fields=["acceptance_criteria", "updated_at"])
|
||||||
|
service.approve_plan(plan)
|
||||||
|
service.materialize_project_dag(plan)
|
||||||
|
|
||||||
|
verification = service.verify_extension(plan)
|
||||||
|
|
||||||
|
assert verification.result == "FAIL"
|
||||||
|
assert candidate.status != "COMPLETE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_evolution_requires_baseline() -> None:
|
||||||
|
proj = Project.objects.create(name="No Baseline", goal="Improve existing system")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
EvolutionService().create_candidate(proj, target="api", objective="reduce latency", baseline_measurement={}, desired_direction="DECREASE")
|
||||||
|
|
||||||
|
|
||||||
|
def test_evolution_graph_can_reject_technical_pass_as_not_improved(tmp_path: Path) -> None:
|
||||||
|
proj = project(tmp_path)
|
||||||
|
service = EvolutionService()
|
||||||
|
candidate = service.create_candidate(proj, target="api latency", objective="reduce duration", baseline_measurement={"metric": "duration", "duration": 100}, desired_direction="DECREASE", target_measurement={"metric": "duration", "duration": 95}, confidence=0.8)
|
||||||
|
service = EvolutionService(sol_router(evolution_payload(candidate, candidate_value=95)))
|
||||||
|
version = champion_project_evolution_graph_v1()
|
||||||
|
graph_run = GraphRun.objects.create(execution_graph_version=version, project=proj, current_node=version.graph_spec["entry"], metadata={"evolution_candidate_id": str(candidate.id)})
|
||||||
|
registry = evolution_registry(service, qwen_router(), test_command=["python", "manage.py", "test"])
|
||||||
|
|
||||||
|
LangGraphRuntime(registry).run_until_terminal_or_paused(graph_run)
|
||||||
|
assert graph_run.status == GraphRunStatus.PAUSED
|
||||||
|
approve_and_resume(graph_run, registry)
|
||||||
|
|
||||||
|
plan = candidate.plans.get()
|
||||||
|
assert graph_run.status == GraphRunStatus.COMPLETE
|
||||||
|
assert plan.verdict == "NOT_IMPROVED"
|
||||||
|
assert plan.status == "NOT_IMPROVED"
|
||||||
|
assert plan.verification.result == "FAIL"
|
||||||
|
assert Task.objects.filter(milestone__plan=plan.project_plan, status=TaskStatus.COMPLETE).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_evolution_service_accepts_measurable_success(tmp_path: Path) -> None:
|
||||||
|
proj = project(tmp_path)
|
||||||
|
candidate = EvolutionService().create_candidate(proj, target="analysis", objective="reduce duration", baseline_measurement={"metric": "duration", "duration": 100}, desired_direction="DECREASE", target_measurement={"metric": "duration", "duration": 80})
|
||||||
|
service = EvolutionService(sol_router(evolution_payload(candidate, candidate_value=80)))
|
||||||
|
plan = service.plan_with_project_brain(candidate)
|
||||||
|
plan.candidate_measurement = {"metric": "duration", "duration": 80}
|
||||||
|
plan.save(update_fields=["candidate_measurement", "updated_at"])
|
||||||
|
|
||||||
|
verification = service.judge_evolution(plan)
|
||||||
|
|
||||||
|
assert verification.result == "PASS"
|
||||||
|
assert plan.verdict == "PASS"
|
||||||
|
|
||||||
|
|
||||||
|
def test_exploration_graph_persists_scores_dedupes_and_does_not_execute(tmp_path: Path) -> None:
|
||||||
|
proj = project(tmp_path)
|
||||||
|
payload = {
|
||||||
|
"opportunities": [
|
||||||
|
{"title": "Add project dashboard", "description": "Show lifecycle status", "opportunity_type": "FEATURE", "evidence": {"files": ["app/urls.py"]}, "rationale": "visibility", "expected_value": "operators see status", "effort_estimate": "MEDIUM", "value": 0.8, "effort": 0.4, "risk_score": 0.2, "confidence": 0.7, "technical_fit": 0.8, "strategic_fit": 0.9, "recommended_action": "EXTEND"},
|
||||||
|
{"title": "Reduce analysis latency", "description": "Measure and reduce repeated reads", "opportunity_type": "PERFORMANCE", "evidence": ["duration metric"], "rationale": "speed", "expected_value": "faster runs", "effort_estimate": "LOW", "value": "faster repeated reads", "effort": 0.2, "risk_score": 0.2, "confidence": 0.6, "technical_fit": 0.7, "strategic_fit": 0.7, "recommended_action": "EVOLVE"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
service = ExplorerService(sol_router(payload))
|
||||||
|
exploration = service.start_exploration(proj)
|
||||||
|
version = champion_project_exploration_graph_v1()
|
||||||
|
graph_run = GraphRun.objects.create(execution_graph_version=version, project=proj, current_node=version.graph_spec["entry"], metadata={"exploration_id": str(exploration.id)})
|
||||||
|
|
||||||
|
LangGraphRuntime(exploration_registry(service)).run_until_terminal_or_paused(graph_run)
|
||||||
|
LangGraphRuntime(exploration_registry(service)).run_until_terminal_or_paused(GraphRun.objects.create(execution_graph_version=version, project=proj, current_node=version.graph_spec["entry"], metadata={"exploration_id": str(service.start_exploration(proj).id)}))
|
||||||
|
|
||||||
|
assert graph_run.status == GraphRunStatus.COMPLETE
|
||||||
|
assert ExplorationOpportunity.objects.filter(project=proj).count() == 2
|
||||||
|
assert Task.objects.filter(project=proj).count() == 0
|
||||||
|
assert all(item.evidence for item in ExplorationOpportunity.objects.filter(project=proj))
|
||||||
|
first = ExplorationOpportunity.objects.filter(project=proj, recommended_action="EXTEND").get()
|
||||||
|
second = ExplorationOpportunity.objects.filter(project=proj, recommended_action="EVOLVE").get()
|
||||||
|
assert second.evidence == {"raw": ["duration metric"]}
|
||||||
|
assert second.value_score == 0.5
|
||||||
|
extension = service.convert_to_extension(first)
|
||||||
|
service.defer(second)
|
||||||
|
assert extension.source_opportunity == first
|
||||||
|
assert first.status == "CONVERTED"
|
||||||
|
assert second.status == "DEFERRED"
|
||||||
|
assert first.composite_score > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_steward_routes_extend_and_evolve_to_project_lifecycle_candidates(tmp_path: Path) -> None:
|
||||||
|
proj = project(tmp_path)
|
||||||
|
policy = StewardPolicy.objects.create(name="Lifecycle Steward", enabled_checks=[], approval_requirements={"EXTEND": "CRITICAL", "EVOLVE": "CRITICAL"})
|
||||||
|
service = StewardService()
|
||||||
|
service.enroll_project(proj, policy)
|
||||||
|
steward_run = service.start_run(proj.steward_enrollments.get())
|
||||||
|
extend = StewardFinding.objects.create(project=proj, steward_run=steward_run, finding_type="PRODUCT_OPPORTUNITY", title="Add dashboard", summary="Dashboard missing", severity="MEDIUM", confidence=0.8, recommended_action="EXTEND", grouping_key="extend")
|
||||||
|
evolve = StewardFinding.objects.create(project=proj, steward_run=steward_run, finding_type="PERFORMANCE_REGRESSION", title="Latency high", summary="Reduce p95 latency", severity="MEDIUM", confidence=0.8, recommended_action="EVOLVE", evidence={"baseline_measurement": {"metric": "latency", "latency": 100}}, grouping_key="evolve")
|
||||||
|
|
||||||
|
extend_action = service.route_finding(extend, policy)
|
||||||
|
evolve_action = service.route_finding(evolve, policy)
|
||||||
|
|
||||||
|
assert extend_action.extension_candidate is not None
|
||||||
|
assert evolve_action.evolution_candidate is not None
|
||||||
|
assert ExtensionCandidate.objects.filter(source_steward_finding=extend).exists()
|
||||||
|
assert EvolutionCandidate.objects.filter(source_steward_finding=evolve).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_lifecycle_inspection_exposes_machine_readable_view(tmp_path: Path) -> None:
|
||||||
|
proj = project(tmp_path)
|
||||||
|
ExplorerService().generate_opportunities(ExplorerService().start_exploration(proj))
|
||||||
|
|
||||||
|
view = LifecycleInspectionService().project_lifecycle_view(proj)
|
||||||
|
|
||||||
|
assert set(view) == {"project_id", "repairs", "extensions", "evolutions", "explorations"}
|
||||||
|
assert view["explorations"][0]["opportunities"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_specs_are_serializable_and_distinct() -> None:
|
||||||
|
assert project_extension_graph_v1().name == "project_extension"
|
||||||
|
assert project_evolution_graph_v1().name == "project_evolution"
|
||||||
|
assert project_exploration_graph_v1().name == "project_exploration"
|
||||||
Loading…
Add table
Reference in a new issue