Fix graph failure metadata and champion rules

This commit is contained in:
Daniel Maddern 2026-08-15 17:43:51 +07:00
parent a798599100
commit b36a1319ec
9 changed files with 246 additions and 21 deletions

View file

@ -26,8 +26,12 @@ def graph_run_inspection(graph_run: GraphRun) -> dict[str, object]:
return {
"graph": graph_run.execution_graph_version.graph.name,
"version": graph_run.execution_graph_version.version,
"version_status": graph_run.execution_graph_version.status,
"version_promoted_at": graph_run.execution_graph_version.promoted_at,
"status": graph_run.status,
"current_node": graph_run.current_node,
"final_failure_reason": graph_run.metadata.get("final_failure_reason") or graph_run.failure_reason or None,
"historical_failures": graph_run.metadata.get("historical_failures", []),
"nodes": nodes,
"edges": spec.get("edges", []),
"edge_traversals": list(

View file

@ -117,9 +117,12 @@ class LangGraphRuntime(GraphRuntime):
edge_result = edge.condition or "success"
break
if target is None:
metadata = dict(graph_run.metadata)
metadata["final_failure_reason"] = f"No edge from {node_id} for {edge_result}"
graph_run.status = GraphRunStatus.FAILED
graph_run.failure_reason = f"No edge from {node_id} for {edge_result}"
graph_run.save(update_fields=["status", "failure_reason", "updated_at"])
graph_run.failure_reason = metadata["final_failure_reason"]
graph_run.metadata = metadata
graph_run.save(update_fields=["status", "failure_reason", "metadata", "updated_at"])
return edge_result
GraphEdgeTraversal.objects.create(graph_run=graph_run, source_node=node_id, target_node=target, condition=edge_result, result=edge_result)
metadata = dict(graph_run.metadata)

View file

@ -0,0 +1,14 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("graph", "0003_graphapproval"),
]
operations = [
migrations.AddConstraint(
model_name="executiongraphversion",
constraint=models.UniqueConstraint(condition=models.Q(("status", "CHAMPION")), fields=("graph",), name="unique_champion_execution_graph_version"),
),
]

View file

@ -1,6 +1,9 @@
from __future__ import annotations
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from django.utils import timezone
class ExecutionGraphVersionStatus(models.TextChoices):
@ -49,11 +52,31 @@ class ExecutionGraphVersion(models.Model):
promoted_at = models.DateTimeField(null=True, blank=True)
class Meta:
constraints = [models.UniqueConstraint(fields=["graph", "version"], name="unique_execution_graph_version")]
constraints = [
models.UniqueConstraint(fields=["graph", "version"], name="unique_execution_graph_version"),
models.UniqueConstraint(fields=["graph"], condition=Q(status="CHAMPION"), name="unique_champion_execution_graph_version"),
]
def __str__(self) -> str:
return f"{self.graph.name} v{self.version}"
def clean(self) -> None:
super().clean()
if self.pk is None:
return
previous = ExecutionGraphVersion.objects.get(pk=self.pk)
if previous.status == ExecutionGraphVersionStatus.CHAMPION:
if self.version != previous.version:
raise ValidationError({"version": "Champion graph versions are immutable; create a new version instead."})
if self.graph_spec != previous.graph_spec:
raise ValidationError({"graph_spec": "Champion graph specs are immutable; create a new version instead."})
def save(self, *args: object, **kwargs: object) -> None:
if self.status == ExecutionGraphVersionStatus.CHAMPION and self.promoted_at is None:
self.promoted_at = timezone.now()
self.full_clean()
super().save(*args, **kwargs)
class GraphRun(models.Model):
execution_graph_version = models.ForeignKey(ExecutionGraphVersion, on_delete=models.PROTECT, related_name="runs")

View file

@ -116,18 +116,24 @@ class NativeGraphRuntime(GraphRuntime):
self.bus.publish("GRAPH_RUN_PAUSED", project=graph_run.project, task=graph_run.task, payload={"graph_run_id": graph_run.id, "reason": result.pause_reason})
break
if result.status == "FAILED":
metadata = dict(graph_run.metadata)
metadata["final_failure_reason"] = str((result.failure_evidence or {}).get("reason", "node failed"))
graph_run.status = GraphRunStatus.FAILED
graph_run.completed_at = timezone.now()
graph_run.failure_reason = str((result.failure_evidence or {}).get("reason", "node failed"))
graph_run.save(update_fields=["status", "completed_at", "failure_reason", "updated_at"])
graph_run.failure_reason = metadata["final_failure_reason"]
graph_run.metadata = metadata
graph_run.save(update_fields=["status", "metadata", "completed_at", "failure_reason", "updated_at"])
self.bus.publish("GRAPH_RUN_FAILED", project=graph_run.project, task=graph_run.task, payload={"graph_run_id": graph_run.id, "node_id": node_id})
break
next_node = self._select_next(spec, node_id, result.edge_result)
if next_node is None:
metadata = dict(graph_run.metadata)
metadata["final_failure_reason"] = f"No edge from {node_id} for {result.edge_result}"
graph_run.status = GraphRunStatus.FAILED
graph_run.completed_at = timezone.now()
graph_run.failure_reason = f"No edge from {node_id} for {result.edge_result}"
graph_run.save(update_fields=["status", "completed_at", "failure_reason", "updated_at"])
graph_run.failure_reason = metadata["final_failure_reason"]
graph_run.metadata = metadata
graph_run.save(update_fields=["status", "metadata", "completed_at", "failure_reason", "updated_at"])
self.bus.publish("GRAPH_RUN_FAILED", project=graph_run.project, task=graph_run.task, payload={"graph_run_id": graph_run.id, "reason": graph_run.failure_reason})
break
GraphEdgeTraversal.objects.create(graph_run=graph_run, source_node=node_id, target_node=next_node, condition=result.edge_result, result=result.edge_result)
@ -188,8 +194,17 @@ class NativeGraphRuntime(GraphRuntime):
def _finish_terminal(self, graph_run: GraphRun, node_id: str) -> None:
graph_run.status = GraphRunStatus.FAILED if node_id == "fail" else GraphRunStatus.COMPLETE
metadata = dict(graph_run.metadata)
if graph_run.status == GraphRunStatus.COMPLETE:
metadata["final_failure_reason"] = None
metadata.pop("current_failure_reason", None)
metadata.pop("current_failure_findings", None)
metadata.pop("current_failure_node_id", None)
else:
metadata["final_failure_reason"] = metadata.get("final_failure_reason") or metadata.get("current_failure_reason") or graph_run.failure_reason or "graph_failed"
graph_run.metadata = metadata
graph_run.completed_at = timezone.now()
graph_run.save(update_fields=["status", "completed_at", "updated_at"])
graph_run.save(update_fields=["status", "metadata", "completed_at", "updated_at"])
event = "GRAPH_RUN_COMPLETED" if graph_run.status == GraphRunStatus.COMPLETE else "GRAPH_RUN_FAILED"
self.bus.publish(event, project=graph_run.project, task=graph_run.task, payload={"graph_run_id": graph_run.id, "terminal_node": node_id})

View file

@ -83,6 +83,13 @@ class TaskNode:
context.graph_run.metadata = metadata
context.graph_run.save(update_fields=["metadata", "updated_at"])
def _clear_current_failure(self, metadata: dict[str, object]) -> None:
metadata.pop("current_failure_reason", None)
metadata.pop("current_failure_findings", None)
metadata.pop("current_failure_node_id", None)
metadata.pop("last_failure_reason", None)
metadata.pop("last_failure_findings", None)
class ClaimTaskNode(TaskNode):
def __init__(self, services: TaskExecutionServices) -> None:
@ -149,8 +156,12 @@ class CoderNode(TaskNode):
result = self.services.coder.execute(attempt.context_snapshot, self.services.tools(worktree), project=task.project, agent_version=coder_version)
attempt.coder_result = {"status": result.status, "summary": result.summary, "changed_files": result.changed_files, "metadata": result.metadata}
attempt.save(update_fields=["coder_result", "updated_at"])
metadata["last_failure_reason"] = "coder_failed"
metadata["last_failure_findings"] = [result.summary]
if result.status != "COMPLETE":
metadata["current_failure_reason"] = "coder_failed"
metadata["current_failure_findings"] = [result.summary]
metadata["current_failure_node_id"] = "coder"
else:
self._clear_current_failure(metadata)
self.save_metadata(context, metadata)
return NodeResult("COMPLETE", "success" if result.status == "COMPLETE" else "failure", {"coder_status": result.status, "summary": result.summary}, result.metadata.get("telemetry", {}) if isinstance(result.metadata, dict) else {})
@ -202,8 +213,12 @@ class ReviewNode(TaskNode):
attempt.review_findings = review.findings
attempt.save(update_fields=["review_findings", "updated_at"])
metadata["review_id"] = str(review.id)
metadata["last_failure_reason"] = "review_failed"
metadata["last_failure_findings"] = review.findings
if review.status != "PASS":
metadata["current_failure_reason"] = "review_failed"
metadata["current_failure_findings"] = review.findings
metadata["current_failure_node_id"] = "review"
else:
self._clear_current_failure(metadata)
self.save_metadata(context, metadata)
if review.status != "PASS":
self.services.bus.publish(EventType.REVIEW_FAILED, project=task.project, task=task, payload={"review_id": str(review.id), "findings": review.findings})
@ -228,8 +243,12 @@ class JudgeNode(TaskNode):
attempt.judge_findings = verification.evidence
attempt.save(update_fields=["judge_findings", "updated_at"])
metadata["verification_id"] = str(verification.id)
metadata["last_failure_reason"] = "judge_failed"
metadata["last_failure_findings"] = verification.evidence
if verification.result != VerificationResult.PASS:
metadata["current_failure_reason"] = "judge_failed"
metadata["current_failure_findings"] = verification.evidence
metadata["current_failure_node_id"] = "judge"
else:
self._clear_current_failure(metadata)
self.save_metadata(context, metadata)
edge = "PASS" if verification.result == VerificationResult.PASS else "FAIL"
return NodeResult("COMPLETE", edge, {"verification_id": str(verification.id), "result": verification.result, "evidence": verification.evidence}, {"judge_result": verification.result})
@ -248,10 +267,14 @@ class RetryOrFailNode(TaskNode):
attempt.status = "REWORK_REQUIRED" if task.retry_count < effective_max_retries else "FAILED"
attempt.save(update_fields=["status", "updated_at"])
task.retry_count += 1
reason = str(metadata.get("last_failure_reason", "task_failed"))
findings = metadata.get("last_failure_findings", [])
reason = str(metadata.get("current_failure_reason", "task_failed"))
findings = metadata.get("current_failure_findings", [])
self._append_historical_failure(metadata, attempt, reason, findings)
classification = self._classify_failure(reason, findings)
metadata.pop("current_attempt_id", None)
if task.retry_count > effective_max_retries:
metadata["final_failure_reason"] = reason
self._clear_current_failure(metadata)
self.save_metadata(context, metadata)
if task.retry_count <= effective_max_retries:
task.status = TaskStatus.RUNNING
@ -280,6 +303,20 @@ class RetryOrFailNode(TaskNode):
return "replan"
return "split_task"
def _append_historical_failure(self, metadata: dict[str, object], attempt: TaskAttempt, reason: str, findings: object) -> None:
failures = list(metadata.get("historical_failures", []))
failures.append(
{
"node_id": metadata.get("current_failure_node_id", "unknown"),
"visit_index": len(failures) + 1,
"attempt_id": str(attempt.id),
"attempt_number": attempt.attempt_number,
"reason": reason,
"evidence": findings,
}
)
metadata["historical_failures"] = failures[-20:]
class CommitNode(TaskNode):
destructive = True

View file

@ -1,6 +1,8 @@
from __future__ import annotations
import pytest
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from control_plane.agents.models import ProgenySignal
from graph.models import ExecutionGraphDefinition, ExecutionGraphVersion, ExecutionGraphVersionStatus, GraphRun, GraphRunStatus
@ -70,6 +72,54 @@ def test_graph_models_persist_versioned_run_state() -> None:
assert run.metadata["node_count"] == 12
def test_champion_graph_version_spec_is_immutable() -> None:
spec = task_execution_graph_v1()
definition = ExecutionGraphDefinition.objects.create(name="immutable_task_execution", graph_type=spec.graph_type)
version = ExecutionGraphVersion.objects.create(graph=definition, version=1, status=ExecutionGraphVersionStatus.CHAMPION, graph_spec=spec.to_dict())
version.graph_spec = {**version.graph_spec, "entry": "different"}
with pytest.raises(ValidationError):
version.save()
def test_graph_changes_require_a_new_version() -> None:
spec = task_execution_graph_v1()
definition = ExecutionGraphDefinition.objects.create(name="versioned_task_execution", graph_type=spec.graph_type)
v1 = ExecutionGraphVersion.objects.create(graph=definition, version=1, status=ExecutionGraphVersionStatus.CHAMPION, graph_spec=spec.to_dict())
changed_spec = spec.to_dict()
changed_spec["metadata"] = {**changed_spec["metadata"], "change": "new node planned"}
v2 = ExecutionGraphVersion.objects.create(graph=definition, version=2, status=ExecutionGraphVersionStatus.CHALLENGER, graph_spec=changed_spec)
assert v1.graph_spec != v2.graph_spec
assert v2.status == ExecutionGraphVersionStatus.CHALLENGER
def test_only_one_champion_version_per_graph_definition() -> None:
spec = task_execution_graph_v1()
definition = ExecutionGraphDefinition.objects.create(name="single_champion_task_execution", graph_type=spec.graph_type)
ExecutionGraphVersion.objects.create(graph=definition, version=1, status=ExecutionGraphVersionStatus.CHAMPION, graph_spec=spec.to_dict())
with pytest.raises((IntegrityError, ValidationError)):
ExecutionGraphVersion.objects.create(graph=definition, version=2, status=ExecutionGraphVersionStatus.CHAMPION, graph_spec=spec.to_dict())
def test_commit_lineage_preserves_exact_graph_version() -> None:
spec = task_execution_graph_v1()
definition = ExecutionGraphDefinition.objects.create(name="lineage_preserved_task_execution", graph_type=spec.graph_type)
v1 = ExecutionGraphVersion.objects.create(graph=definition, version=1, status=ExecutionGraphVersionStatus.CHAMPION, graph_spec=spec.to_dict())
v2 = ExecutionGraphVersion.objects.create(graph=definition, version=2, status=ExecutionGraphVersionStatus.CHALLENGER, graph_spec=spec.to_dict())
run = GraphRun.objects.create(execution_graph_version=v1, status=GraphRunStatus.COMPLETE, current_node="complete")
v2.status = ExecutionGraphVersionStatus.RETIRED
v2.save()
run.refresh_from_db()
assert run.execution_graph_version == v1
assert run.execution_graph_version.version == 1
def test_progeny_signal_can_reference_graph_lineage() -> None:
spec = task_execution_graph_v1()
definition = ExecutionGraphDefinition.objects.create(name="lineage_graph", graph_type=spec.graph_type)

View file

@ -99,4 +99,30 @@ def test_graph_run_inspection_exposes_ui_ready_shape() -> None:
assert inspection["graph"] == "inspect_fixture"
assert inspection["current_node"] == "done"
assert inspection["version_status"] == ExecutionGraphVersionStatus.CHAMPION
assert inspection["final_failure_reason"] is None
assert inspection["historical_failures"] == []
assert inspection["nodes"] == [{"id": "done", "type": "done_node", "status": "PENDING", "visit_index": 0, "duration_ms": None, "failure": {}, "metadata": {}}]
def test_graph_run_inspection_separates_final_and_historical_failures() -> None:
spec = ExecutionGraphSpec(
name="inspect_failures_fixture",
version=1,
graph_type="FIXTURE",
entry="done",
nodes={"done": GraphNodeSpec("done", "done_node")},
edges=[],
terminal_nodes=["done"],
)
graph_run = persist_spec(spec)
graph_run.metadata = {
"final_failure_reason": None,
"historical_failures": [{"node_id": "review", "reason": "review_failed", "evidence": [{"type": "missing_route"}]}],
}
graph_run.save(update_fields=["metadata", "updated_at"])
inspection = graph_run_inspection(graph_run)
assert inspection["final_failure_reason"] is None
assert inspection["historical_failures"] == [{"node_id": "review", "reason": "review_failed", "evidence": [{"type": "missing_route"}]}]

View file

@ -12,7 +12,7 @@ from graph.native_runtime import NativeGraphRuntime
from graph.langgraph_runtime import LangGraphRuntime
from graph.task_execution import task_execution_graph_v1
from graph.task_nodes import TaskExecutionServices, task_execution_registry
from model_router.router import ModelRouter
from model_router.router import ModelResponseContract, ModelRouter
from tests.test_m2_autonomous_loop import create_disposable_django_repo
@ -52,16 +52,33 @@ def graph_run_for_task(task: Task) -> GraphRun:
)
def run_graph(task: Task, *, interrupt_after: str | None = None) -> GraphRun:
class FirstAttemptBadProvider(DeterministicCodingProvider):
def __init__(self) -> None:
self.edit_calls = 0
def complete(self, request):
if "inspection phase" in request.prompt.lower():
return super().complete(request)
self.edit_calls += 1
if self.edit_calls == 1:
return ModelResponseContract(
"qwen-deterministic",
"Wrote intentionally insufficient change.",
{"operations": [{"type": "write_file", "path": "bad.txt", "content": "not enough\n"}]},
)
return super().complete(request)
def run_graph(task: Task, *, interrupt_after: str | None = None, provider=None) -> GraphRun:
SeedAgentsCommand().handle()
services = TaskExecutionServices(ModelRouter({"qwen": DeterministicCodingProvider()}), test_command=["python", "manage.py", "test"])
services = TaskExecutionServices(ModelRouter({"qwen": provider or DeterministicCodingProvider()}), test_command=["python", "manage.py", "test"])
runtime = NativeGraphRuntime(task_execution_registry(services))
return runtime.run_until_terminal_or_paused(graph_run_for_task(task), interrupt_after=interrupt_after)
def run_langgraph(task: Task, *, interrupt_after: str | None = None) -> GraphRun:
def run_langgraph(task: Task, *, interrupt_after: str | None = None, provider=None) -> GraphRun:
SeedAgentsCommand().handle()
services = TaskExecutionServices(ModelRouter({"qwen": DeterministicCodingProvider()}), test_command=["python", "manage.py", "test"])
services = TaskExecutionServices(ModelRouter({"qwen": provider or DeterministicCodingProvider()}), test_command=["python", "manage.py", "test"])
runtime = LangGraphRuntime(task_execution_registry(services))
return runtime.run_until_terminal_or_paused(graph_run_for_task(task), interrupt_after=interrupt_after)
@ -81,6 +98,9 @@ def test_native_task_execution_graph_success_matches_loop_semantics(tmp_path: Pa
assert Verification.objects.get(task=task).result == VerificationResult.PASS
assert task.worktree.status == "CLEANED"
assert GraphEdgeTraversal.objects.filter(graph_run=graph_run, source_node="judge", target_node="commit", condition="PASS").exists()
assert graph_run.metadata["final_failure_reason"] is None
assert "current_failure_reason" not in graph_run.metadata
assert "last_failure_reason" not in graph_run.metadata
def test_native_task_execution_graph_retry_exhaustion_matches_loop_semantics(tmp_path: Path) -> None:
@ -96,6 +116,8 @@ def test_native_task_execution_graph_retry_exhaustion_matches_loop_semantics(tmp
assert CommitRecord.objects.filter(task=task).count() == 0
assert Event.objects.filter(task=task, event_type=EventType.TASK_FAILED).exists()
assert Event.objects.filter(task=task, event_type="TASK_RETRY_EXHAUSTED").exists()
assert graph_run.metadata["final_failure_reason"] == "review_failed"
assert graph_run.metadata["historical_failures"][-1]["reason"] == "review_failed"
def test_native_task_execution_graph_resume_after_coder_prevents_duplicate_commit(tmp_path: Path) -> None:
@ -132,10 +154,15 @@ def test_langgraph_task_execution_graph_success_matches_native_domain_outcome(tm
assert task.status == TaskStatus.COMPLETE
commit = CommitRecord.objects.get(task=task)
assert commit.graph_run == graph_run
assert commit.graph_run.execution_graph_version == graph_run.execution_graph_version
assert TestRun.objects.get(task=task).status == "PASS"
assert Review.objects.get(task=task).status == "PASS"
assert Verification.objects.get(task=task).result == VerificationResult.PASS
assert GraphEdgeTraversal.objects.filter(graph_run=graph_run, source_node="review", target_node="judge", condition="PASS").exists()
assert graph_run.metadata["final_failure_reason"] is None
assert "current_failure_reason" not in graph_run.metadata
assert "last_failure_reason" not in graph_run.metadata
assert graph_run.metadata["last_node_id"] == "cleanup"
def test_langgraph_task_execution_graph_retry_exhaustion_matches_native_domain_outcome(tmp_path: Path) -> None:
@ -150,6 +177,32 @@ def test_langgraph_task_execution_graph_retry_exhaustion_matches_native_domain_o
assert task.retry_count == 2
assert CommitRecord.objects.filter(task=task).count() == 0
assert Event.objects.filter(task=task, event_type="TASK_RETRY_EXHAUSTED").exists()
assert graph_run.metadata["final_failure_reason"] == "review_failed"
assert graph_run.metadata["historical_failures"][-1]["reason"] == "review_failed"
def test_langgraph_retry_then_success_preserves_historical_failures_without_final_failure(tmp_path: Path) -> None:
repo = create_disposable_django_repo(tmp_path)
task = create_task(repo, "Add a /health endpoint returning JSON {\"status\": \"ok\"} and add tests.", ["/health returns JSON ok", "tests pass"])
graph_run = run_langgraph(task, provider=FirstAttemptBadProvider())
task.refresh_from_db()
assert graph_run.status == GraphRunStatus.COMPLETE
assert task.status == TaskStatus.COMPLETE
assert task.retry_count == 1
assert graph_run.metadata["final_failure_reason"] is None
assert "current_failure_reason" not in graph_run.metadata
assert graph_run.metadata["historical_failures"] == [
{
"node_id": "review",
"visit_index": 1,
"attempt_id": str(task.attempts.order_by("attempt_number").first().id),
"attempt_number": 1,
"reason": "review_failed",
"evidence": [{"type": "missing_health_route", "severity": "high", "message": "Diff does not add /health"}],
}
]
def test_langgraph_resume_after_coder_tests_and_judge_does_not_duplicate_commit(tmp_path: Path) -> None: