From b36a1319ec89049aa0ad8f5d6fa544ebf45020d5 Mon Sep 17 00:00:00 2001 From: Daniel Maddern Date: Sat, 15 Aug 2026 17:43:51 +0700 Subject: [PATCH] Fix graph failure metadata and champion rules --- graph/inspection.py | 4 ++ graph/langgraph_runtime.py | 7 ++- .../0004_unique_champion_graph_version.py | 14 +++++ graph/models.py | 25 +++++++- graph/native_runtime.py | 25 ++++++-- graph/task_nodes.py | 53 +++++++++++++--- tests/test_execution_graph_phase_a.py | 50 +++++++++++++++ tests/test_graph_pause_subgraph_inspection.py | 26 ++++++++ tests/test_task_execution_native_graph.py | 63 +++++++++++++++++-- 9 files changed, 246 insertions(+), 21 deletions(-) create mode 100644 graph/migrations/0004_unique_champion_graph_version.py diff --git a/graph/inspection.py b/graph/inspection.py index 3a16827..039662d 100644 --- a/graph/inspection.py +++ b/graph/inspection.py @@ -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( diff --git a/graph/langgraph_runtime.py b/graph/langgraph_runtime.py index eb5f33c..0e86f15 100644 --- a/graph/langgraph_runtime.py +++ b/graph/langgraph_runtime.py @@ -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) diff --git a/graph/migrations/0004_unique_champion_graph_version.py b/graph/migrations/0004_unique_champion_graph_version.py new file mode 100644 index 0000000..5974b17 --- /dev/null +++ b/graph/migrations/0004_unique_champion_graph_version.py @@ -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"), + ), + ] diff --git a/graph/models.py b/graph/models.py index 327fdb0..90a69dd 100644 --- a/graph/models.py +++ b/graph/models.py @@ -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") diff --git a/graph/native_runtime.py b/graph/native_runtime.py index 332d9dd..8e72930 100644 --- a/graph/native_runtime.py +++ b/graph/native_runtime.py @@ -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}) diff --git a/graph/task_nodes.py b/graph/task_nodes.py index ece3049..4769746 100644 --- a/graph/task_nodes.py +++ b/graph/task_nodes.py @@ -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 diff --git a/tests/test_execution_graph_phase_a.py b/tests/test_execution_graph_phase_a.py index 821ad37..5330714 100644 --- a/tests/test_execution_graph_phase_a.py +++ b/tests/test_execution_graph_phase_a.py @@ -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) diff --git a/tests/test_graph_pause_subgraph_inspection.py b/tests/test_graph_pause_subgraph_inspection.py index 174e3bb..9cada76 100644 --- a/tests/test_graph_pause_subgraph_inspection.py +++ b/tests/test_graph_pause_subgraph_inspection.py @@ -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"}]}] diff --git a/tests/test_task_execution_native_graph.py b/tests/test_task_execution_native_graph.py index 6e55e7c..8619584 100644 --- a/tests/test_task_execution_native_graph.py +++ b/tests/test_task_execution_native_graph.py @@ -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: