Fix graph failure metadata and champion rules
This commit is contained in:
parent
a798599100
commit
b36a1319ec
9 changed files with 246 additions and 21 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
14
graph/migrations/0004_unique_champion_graph_version.py
Normal file
14
graph/migrations/0004_unique_champion_graph_version.py
Normal 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"),
|
||||
),
|
||||
]
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"}]}]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue