Compare commits
No commits in common. "e300e13e1ab90d17c6db74b8ae79e8e75d660f80" and "6a73372665715950aee8416a9d2b3ec79955f816" have entirely different histories.
e300e13e1a
...
6a73372665
30 changed files with 8 additions and 2410 deletions
|
|
@ -24,7 +24,6 @@ INSTALLED_APPS = [
|
||||||
"control_plane.secrets",
|
"control_plane.secrets",
|
||||||
"control_plane.knowledge",
|
"control_plane.knowledge",
|
||||||
"control_plane.verification",
|
"control_plane.verification",
|
||||||
"graph",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("agents", "0004_progenysignal"),
|
|
||||||
("graph", "0003_graphapproval"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="progenysignal",
|
|
||||||
name="execution_graph_version",
|
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="graph.executiongraphversion"),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="progenysignal",
|
|
||||||
name="graph_node_run",
|
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="graph.graphnoderun"),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="progenysignal",
|
|
||||||
name="graph_run",
|
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="graph.graphrun"),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -88,9 +88,6 @@ class ProgenySignal(TimestampedModel):
|
||||||
task = models.ForeignKey("projects.Task", on_delete=models.SET_NULL, null=True, blank=True)
|
task = models.ForeignKey("projects.Task", on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
milestone = models.ForeignKey("projects.Milestone", on_delete=models.SET_NULL, null=True, blank=True)
|
milestone = models.ForeignKey("projects.Milestone", on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
agent_version = models.ForeignKey(AgentVersion, on_delete=models.SET_NULL, null=True, blank=True)
|
agent_version = models.ForeignKey(AgentVersion, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
graph_run = models.ForeignKey("graph.GraphRun", on_delete=models.SET_NULL, null=True, blank=True)
|
|
||||||
graph_node_run = models.ForeignKey("graph.GraphNodeRun", on_delete=models.SET_NULL, null=True, blank=True)
|
|
||||||
execution_graph_version = models.ForeignKey("graph.ExecutionGraphVersion", on_delete=models.SET_NULL, null=True, blank=True)
|
|
||||||
source = models.CharField(max_length=80)
|
source = models.CharField(max_length=80)
|
||||||
severity = models.CharField(max_length=32, default="INFO")
|
severity = models.CharField(max_length=32, default="INFO")
|
||||||
failure_category = models.CharField(max_length=80, blank=True)
|
failure_category = models.CharField(max_length=80, blank=True)
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("graph", "0002_graphnoderun_visit_index"),
|
|
||||||
("projects", "0002_commitrecord_coder_commitrecord_judge_and_more"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="commitrecord",
|
|
||||||
name="graph_run",
|
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="commits", to="graph.graphrun"),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -173,9 +173,6 @@ class CommitRecord(TimestampedModel):
|
||||||
verification = models.ForeignKey(
|
verification = models.ForeignKey(
|
||||||
"verification.Verification", on_delete=models.SET_NULL, null=True, blank=True, related_name="commits"
|
"verification.Verification", on_delete=models.SET_NULL, null=True, blank=True, related_name="commits"
|
||||||
)
|
)
|
||||||
graph_run = models.ForeignKey(
|
|
||||||
"graph.GraphRun", 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()
|
||||||
|
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
# Execution Graph Architecture
|
|
||||||
|
|
||||||
Date: 2026-08-15
|
|
||||||
|
|
||||||
## Implemented Architecture
|
|
||||||
|
|
||||||
Artifex now has two intentionally separate graph concepts.
|
|
||||||
|
|
||||||
Project DAG remains the canonical representation of what work exists:
|
|
||||||
|
|
||||||
- `Project`
|
|
||||||
- `Milestone`
|
|
||||||
- `Feature`
|
|
||||||
- `Task`
|
|
||||||
- `TaskDependency`
|
|
||||||
|
|
||||||
Execution Graph is now the canonical representation of how autonomous work is performed. It is runtime-neutral and persisted in Django/Postgres.
|
|
||||||
|
|
||||||
## Graph Domain Models
|
|
||||||
|
|
||||||
Implemented graph domain models:
|
|
||||||
|
|
||||||
- `ExecutionGraphDefinition`
|
|
||||||
- `ExecutionGraphVersion`
|
|
||||||
- `GraphRun`
|
|
||||||
- `GraphNodeRun`
|
|
||||||
- `GraphEdgeTraversal`
|
|
||||||
- `GraphApproval`
|
|
||||||
|
|
||||||
Execution graph version statuses:
|
|
||||||
|
|
||||||
- `DRAFT`
|
|
||||||
- `CHALLENGER`
|
|
||||||
- `CHAMPION`
|
|
||||||
- `RETIRED`
|
|
||||||
|
|
||||||
Graph run statuses:
|
|
||||||
|
|
||||||
- `PENDING`
|
|
||||||
- `RUNNING`
|
|
||||||
- `PAUSED`
|
|
||||||
- `COMPLETE`
|
|
||||||
- `FAILED`
|
|
||||||
- `CANCELLED`
|
|
||||||
|
|
||||||
Node runs persist status, timing, input/output metadata, failure evidence, telemetry, optional agent version, and optional model request reference.
|
|
||||||
|
|
||||||
Edge traversals persist selected conditional transitions.
|
|
||||||
|
|
||||||
## Graph Specification
|
|
||||||
|
|
||||||
Graph specs are serializable dictionaries with:
|
|
||||||
|
|
||||||
- name
|
|
||||||
- version
|
|
||||||
- graph type
|
|
||||||
- entry node
|
|
||||||
- nodes
|
|
||||||
- edges
|
|
||||||
- conditional edge labels
|
|
||||||
- terminal nodes
|
|
||||||
- metadata
|
|
||||||
|
|
||||||
Specs do not execute arbitrary Python. Node execution resolves through `NodeHandlerRegistry`.
|
|
||||||
|
|
||||||
## TaskExecutionGraph V1
|
|
||||||
|
|
||||||
`TaskExecutionGraph v1` mirrors the prior `AutonomousTaskLoop` behavior.
|
|
||||||
|
|
||||||
Nodes:
|
|
||||||
|
|
||||||
- `claim_task`
|
|
||||||
- `prepare_worktree`
|
|
||||||
- `build_context`
|
|
||||||
- `coder`
|
|
||||||
- `run_tests`
|
|
||||||
- `review`
|
|
||||||
- `judge`
|
|
||||||
- `commit`
|
|
||||||
- `retry_or_fail`
|
|
||||||
- `cleanup`
|
|
||||||
- `complete`
|
|
||||||
- `fail`
|
|
||||||
|
|
||||||
Important semantics preserved:
|
|
||||||
|
|
||||||
- deterministic test failure still reaches Reviewer
|
|
||||||
- Reviewer failure goes to retry/fail
|
|
||||||
- Judge failure goes to retry/fail
|
|
||||||
- maximum semantic task attempts remains three total attempts
|
|
||||||
- commit is guarded against duplicate commits on resume
|
|
||||||
- CoderToolLoop remains inside the `coder` node
|
|
||||||
|
|
||||||
## NativeGraphRuntime
|
|
||||||
|
|
||||||
`NativeGraphRuntime` is the reference implementation.
|
|
||||||
|
|
||||||
It supports:
|
|
||||||
|
|
||||||
- conditional edges
|
|
||||||
- loops
|
|
||||||
- persisted graph runs
|
|
||||||
- persisted node runs
|
|
||||||
- edge traversal history
|
|
||||||
- checkpoint/resume at major node boundaries
|
|
||||||
- terminal success/failure
|
|
||||||
- pause state
|
|
||||||
- cancellation
|
|
||||||
- graph events
|
|
||||||
- bounded metadata
|
|
||||||
|
|
||||||
Django/Postgres remains canonical for task, attempt, worktree, test, review, judge, commit, event, agent, and Progeny state.
|
|
||||||
|
|
||||||
## AutonomousTaskLoop Delegation
|
|
||||||
|
|
||||||
`AutonomousTaskLoop` still uses `TaskScheduler` to claim Project DAG work.
|
|
||||||
|
|
||||||
After a task is claimed, it now creates a `GraphRun` for the champion `TaskExecutionGraph v1` and delegates task lifecycle execution to `NativeGraphRuntime`.
|
|
||||||
|
|
||||||
The scheduler remains Project-DAG-oriented. The execution runtime handles task-attempt workflow.
|
|
||||||
|
|
||||||
## LangGraphRuntime
|
|
||||||
|
|
||||||
`LangGraphRuntime` is no longer a pure placeholder. It builds a LangGraph `StateGraph` from Artifex graph definitions when the `langgraph` package is installed.
|
|
||||||
|
|
||||||
Artifex remains runtime-neutral:
|
|
||||||
|
|
||||||
- Artifex owns graph definitions
|
|
||||||
- Artifex owns persisted graph state
|
|
||||||
- Artifex owns node contracts
|
|
||||||
- LangGraph is an execution backend
|
|
||||||
|
|
||||||
Current limitation: the local environment used during implementation did not have `langgraph` installed, so deterministic tests validate the adapter boundary and missing-dependency behavior. Full LangGraph execution parity requires installing the declared `langgraph>=0.2,<0.3` dependency in local/Spark environments.
|
|
||||||
|
|
||||||
## Checkpoint And Resume
|
|
||||||
|
|
||||||
Coarse checkpointing is implemented at graph node boundaries.
|
|
||||||
|
|
||||||
Resume behavior avoids repeating a completed interrupted node. Loop re-entry creates a new `GraphNodeRun` visit via `visit_index`.
|
|
||||||
|
|
||||||
Irreversible commit behavior is guarded by checking for an existing `CommitRecord` for the task before creating a new commit.
|
|
||||||
|
|
||||||
## Human Approval
|
|
||||||
|
|
||||||
Graph approval support is implemented with `GraphApproval`.
|
|
||||||
|
|
||||||
A node can pause the graph with `AWAITING_APPROVAL`. A signal can approve pending graph approvals and resume execution.
|
|
||||||
|
|
||||||
This is intentionally minimal and prepares future gates for risky migrations, deployment, Progeny promotion, destructive infrastructure changes, and project plan approvals.
|
|
||||||
|
|
||||||
## Subgraph Support
|
|
||||||
|
|
||||||
The graph spec supports runtime-neutral subgraph representation through node metadata.
|
|
||||||
|
|
||||||
CoderToolLoop is not moved into a subgraph yet. It remains inside the `coder` node.
|
|
||||||
|
|
||||||
## Graph Inspection
|
|
||||||
|
|
||||||
`graph_run_inspection()` exposes UI-ready JSON containing:
|
|
||||||
|
|
||||||
- graph/version
|
|
||||||
- node list
|
|
||||||
- edges
|
|
||||||
- node statuses
|
|
||||||
- current node
|
|
||||||
- durations
|
|
||||||
- failures
|
|
||||||
- selected edge traversals
|
|
||||||
|
|
||||||
This is sufficient for a future visual execution graph UI.
|
|
||||||
|
|
||||||
## Progeny Integration
|
|
||||||
|
|
||||||
`ProgenySignal` can now reference:
|
|
||||||
|
|
||||||
- `graph_run`
|
|
||||||
- `graph_node_run`
|
|
||||||
- `execution_graph_version`
|
|
||||||
|
|
||||||
This enables future investigations such as:
|
|
||||||
|
|
||||||
- failures by graph version
|
|
||||||
- failures by node type
|
|
||||||
- patch success rate by workflow version
|
|
||||||
- Reviewer rework changes after inserting a verification node
|
|
||||||
|
|
||||||
## Migrations
|
|
||||||
|
|
||||||
New migrations:
|
|
||||||
|
|
||||||
- `graph/0001_initial.py`
|
|
||||||
- `graph/0002_graphnoderun_visit_index.py`
|
|
||||||
- `graph/0003_graphapproval.py`
|
|
||||||
- `projects/0003_commitrecord_graph_run.py`
|
|
||||||
- `agents/0005_progenysignal_graph_lineage.py`
|
|
||||||
|
|
||||||
## Known Limitations
|
|
||||||
|
|
||||||
- Full LangGraph parity execution is implemented but not exercised in this environment because `langgraph` is not installed.
|
|
||||||
- Parallel branch execution is represented by the graph model but not executed concurrently by `NativeGraphRuntime`.
|
|
||||||
- Subgraph support is represented in the spec but not yet expanded into nested `GraphRun` execution.
|
|
||||||
- CoderToolLoop remains attempt-granularity for checkpointing.
|
|
||||||
- Graph telemetry is persisted at node level, but model request references are not yet automatically linked to individual node runs.
|
|
||||||
- Event Bus records graph facts but is not an orchestration engine.
|
|
||||||
|
|
@ -1,524 +0,0 @@
|
||||||
# Graph Runtime Audit
|
|
||||||
|
|
||||||
Date: 2026-08-15
|
|
||||||
|
|
||||||
Baseline inspected: `6a73372665715950aee8416a9d2b3ec79955f816`
|
|
||||||
|
|
||||||
Scope: audit current Artifex implementation against the intended graph-runtime architecture. This document does not propose or include a refactor.
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
`GraphRuntime` is currently an interface boundary, not the production execution engine.
|
|
||||||
|
|
||||||
`LangGraphRuntime` is a deterministic placeholder adapter. It is not executing project, milestone, feature, task, Coder, Reviewer, Judge, retry, or commit workflows.
|
|
||||||
|
|
||||||
Real production task execution is driven by `runtime_loop.autonomous_task_loop.AutonomousTaskLoop`, `graph.scheduler.TaskScheduler`, Django ORM state, direct Python service calls, and persisted Event Bus events.
|
|
||||||
|
|
||||||
The current implementation has two separate concepts that are easy to conflate:
|
|
||||||
|
|
||||||
- Project DAG: persisted project work decomposition and task dependencies.
|
|
||||||
- Execution Graph: the operational workflow for executing one task attempt through Coder, tools, tests, Reviewer, Judge, retry, and commit.
|
|
||||||
|
|
||||||
The Project DAG exists today in Django models and scheduler logic. The Execution Graph exists only as imperative Python control flow inside `AutonomousTaskLoop`; it is not represented as a graph runtime yet.
|
|
||||||
|
|
||||||
Recommendation: keep Django/Postgres as canonical state and migrate execution to graph semantics incrementally. Start by extracting an explicit `TaskExecutionGraph` description around the existing task loop without changing behavior, then add graph telemetry/checkpoint metadata. LangGraph should remain behind `GraphRuntime` and should provide value only when conditional routing, resumable execution, human gates, subgraphs, and agent-team workflows become difficult to manage in plain Python.
|
|
||||||
|
|
||||||
## Direct Answers
|
|
||||||
|
|
||||||
### 1. Is `GraphRuntime` used in production task execution?
|
|
||||||
|
|
||||||
No.
|
|
||||||
|
|
||||||
`graph/runtime.py` defines an abstract `GraphRuntime` with `start`, `pause`, `resume`, `cancel`, and `signal`, but no production task execution path depends on it.
|
|
||||||
|
|
||||||
Observed production path:
|
|
||||||
|
|
||||||
- `runtime_loop/autonomous_task_loop.py` imports `TaskScheduler`, `Coder`, `Reviewer`, `Judge`, `WorktreeTools`, and `DeterministicTestRunner`.
|
|
||||||
- It does not import or call `GraphRuntime` or `LangGraphRuntime`.
|
|
||||||
|
|
||||||
Current role of `GraphRuntime`: architectural boundary and future abstraction.
|
|
||||||
|
|
||||||
### 2. Is `LangGraphRuntime` actually executing task workflows?
|
|
||||||
|
|
||||||
No.
|
|
||||||
|
|
||||||
`graph/langgraph_runtime.py` returns a deterministic string from `start` and no-ops `pause`, `resume`, `cancel`, and `signal`.
|
|
||||||
|
|
||||||
It does not import LangGraph, build a state graph, execute graph nodes, route edges, persist graph checkpoints, or call Coder/Reviewer/Judge.
|
|
||||||
|
|
||||||
Its only observed test coverage is `tests/test_interfaces.py::test_langgraph_runtime_is_hidden_behind_runtime_interface`, which checks that `start()` returns a run id prefix.
|
|
||||||
|
|
||||||
## Real Execution Path
|
|
||||||
|
|
||||||
Current task execution path for a single task:
|
|
||||||
|
|
||||||
1. Scheduler
|
|
||||||
|
|
||||||
`AutonomousTaskLoop.run_once()` calls `TaskScheduler.claim_next_ready_task()`.
|
|
||||||
|
|
||||||
`TaskScheduler` queries `Task(status=READY)` ordered by priority and creation time, skips tasks with incomplete dependencies, marks the selected task `RUNNING`, persists it, and publishes `TASK_STARTED`.
|
|
||||||
|
|
||||||
2. Investigation/context building
|
|
||||||
|
|
||||||
`AutonomousTaskLoop._execute_task()` creates or reuses a worktree and builds context with `WorkerContextBuilder.build_for_task()`.
|
|
||||||
|
|
||||||
This context builder reads selected Python files and tests directly from the worktree, excludes migrations and secrets, and attaches the current diff.
|
|
||||||
|
|
||||||
Coder also has an inspect-before-edit phase inside `Coder.execute()` for sensitive tasks. It asks the model for read-only inspection operations, then executes `list_directory`, `read_file`, `search_code`, `find_symbol`, `git_status`, or `git_diff` through `WorktreeTools`.
|
|
||||||
|
|
||||||
3. Coder
|
|
||||||
|
|
||||||
`AutonomousTaskLoop` calls `Coder.execute(context, tools, project, agent_version)`.
|
|
||||||
|
|
||||||
`Coder.execute()` may perform inspection, then creates a `CoderToolLoop` and calls `CoderToolLoop.run()`.
|
|
||||||
|
|
||||||
4. Mutation
|
|
||||||
|
|
||||||
`CoderToolLoop` asks the model for mutation operations and applies them through `WorktreeTools`.
|
|
||||||
|
|
||||||
Supported mutation operations include:
|
|
||||||
|
|
||||||
- `write_file`
|
|
||||||
- `apply_patch`
|
|
||||||
- `delete_file`
|
|
||||||
- `move_file`
|
|
||||||
- `create_directory`
|
|
||||||
- `run_command`
|
|
||||||
|
|
||||||
Patch mismatch recovery is internal to `CoderToolLoop`: it records structured failure evidence, re-reads the live target file excerpt, and asks the model once more for a corrected patch. It does not default to full-file replacement.
|
|
||||||
|
|
||||||
5. Tests
|
|
||||||
|
|
||||||
`AutonomousTaskLoop` calls `DeterministicTestRunner.run()`.
|
|
||||||
|
|
||||||
`DeterministicTestRunner` shells out via `WorktreeTools.run()`, persists an `Artifact` with stdout/stderr/returncode, and creates a `TestRun` with `PASS` or `FAIL`.
|
|
||||||
|
|
||||||
On failure, `_attach_test_failure_evidence()` stores stdout/stderr excerpts into the attempt's `coder_result.metadata.test_failure_evidence`, so later retry context can include the actual traceback.
|
|
||||||
|
|
||||||
6. Reviewer
|
|
||||||
|
|
||||||
`AutonomousTaskLoop` stages intent-to-add with `git add -N`, gets `git diff`, then calls `Reviewer.review(task, reviewer_version, diff, test_status)`.
|
|
||||||
|
|
||||||
`Reviewer` is currently a deterministic Python policy. It creates a `Review` row with `PASS`, `REWORK_REQUIRED`, or `REJECTED` based on test status, empty diff, and health-route-specific heuristics.
|
|
||||||
|
|
||||||
7. Judge
|
|
||||||
|
|
||||||
If Reviewer passes, `AutonomousTaskLoop` calls `Judge.judge(project, task, judge_version, diff, test_status)`.
|
|
||||||
|
|
||||||
`Judge` is currently deterministic Python. It creates a `Verification` row with task-level acceptance evidence and `PASS` or `FAIL`.
|
|
||||||
|
|
||||||
8. Retry
|
|
||||||
|
|
||||||
If Coder, Reviewer, or Judge fails, `_retry_or_fail()` updates `TaskAttempt.status`, increments `Task.retry_count`, publishes `TASK_FAILED`, and either loops for another attempt or marks the task `FAILED` and publishes `TASK_RETRY_EXHAUSTED` plus final `TASK_FAILED`.
|
|
||||||
|
|
||||||
The effective retry budget is capped at two retries, giving three total attempts.
|
|
||||||
|
|
||||||
9. Commit
|
|
||||||
|
|
||||||
If tests, Reviewer, and Judge all pass, `WorktreeTools.commit_all()` stages and commits all changes. `CommitRecord` is persisted with coder, reviewer, judge, test run, review, verification, SHA, branch, and message.
|
|
||||||
|
|
||||||
The task is marked `COMPLETE`, `COMMIT_CREATED` and `TASK_COMPLETED` are published with telemetry, the worktree is validated clean, and the task worktree is cleaned up.
|
|
||||||
|
|
||||||
## Transition Classification
|
|
||||||
|
|
||||||
### Explicit graph nodes/edges
|
|
||||||
|
|
||||||
Currently only the Project DAG has explicit persisted edges:
|
|
||||||
|
|
||||||
- `TaskDependency(task, depends_on)` models task-to-task dependencies.
|
|
||||||
- `Milestone.dependencies` models milestone-level dependencies but is not currently central in execution scheduling.
|
|
||||||
- `ProjectPlanBuilder` validates task dependency acyclicity before materializing tasks.
|
|
||||||
- `TaskScheduler.claim_next_ready_task()` treats incomplete `TaskDependency` rows as blocking edges.
|
|
||||||
|
|
||||||
No explicit graph nodes/edges currently model the execution workflow inside a task attempt.
|
|
||||||
|
|
||||||
### Direct Python service calls
|
|
||||||
|
|
||||||
Most real execution transitions are direct service calls:
|
|
||||||
|
|
||||||
- `AutonomousTaskLoop.run_once()` -> `TaskScheduler.claim_next_ready_task()`
|
|
||||||
- `_execute_task()` -> `WorkerContextBuilder.build_for_task()`
|
|
||||||
- `_execute_task()` -> `Coder.execute()`
|
|
||||||
- `Coder.execute()` -> `CoderToolLoop.run()`
|
|
||||||
- `CoderToolLoop` -> `ModelRouter.complete()`
|
|
||||||
- `CoderToolLoop` -> `WorktreeTools` mutation/read/test helpers
|
|
||||||
- `_execute_task()` -> `DeterministicTestRunner.run()`
|
|
||||||
- `_execute_task()` -> `Reviewer.review()`
|
|
||||||
- `_execute_task()` -> `Judge.judge()`
|
|
||||||
- `_execute_task()` -> `WorktreeTools.commit_all()`
|
|
||||||
- `_retry_or_fail()` direct retry/fail transition
|
|
||||||
|
|
||||||
### Django state transitions
|
|
||||||
|
|
||||||
Django/Postgres is the canonical durable state machine today:
|
|
||||||
|
|
||||||
- `Task.status`: `READY`, `RUNNING`, `FAILED`, `COMPLETE`
|
|
||||||
- `Task.retry_count`
|
|
||||||
- `TaskAttempt.status`: `RUNNING`, `REWORK_REQUIRED`, `FAILED`, `COMPLETE`
|
|
||||||
- `Worktree.status`
|
|
||||||
- `TestRun.status`
|
|
||||||
- `Review.status`
|
|
||||||
- `Verification.result`
|
|
||||||
- `CommitRecord`
|
|
||||||
- `ModelRequest.status`
|
|
||||||
- `ProgenySignal.status`
|
|
||||||
|
|
||||||
### Event Bus driven transitions
|
|
||||||
|
|
||||||
The Event Bus persists facts but does not currently drive the control flow.
|
|
||||||
|
|
||||||
Events published today include:
|
|
||||||
|
|
||||||
- `PLAN_APPROVED`
|
|
||||||
- `MILESTONE_CREATED`
|
|
||||||
- `TASK_CREATED`
|
|
||||||
- `TASK_READY`
|
|
||||||
- `TASK_STARTED`
|
|
||||||
- `TEST_FAILED`
|
|
||||||
- `REVIEW_FAILED`
|
|
||||||
- `TASK_FAILED`
|
|
||||||
- `TASK_RETRY_EXHAUSTED` as a string event
|
|
||||||
- `COMMIT_CREATED`
|
|
||||||
- `TASK_COMPLETED`
|
|
||||||
- `AGENT_CREATED`
|
|
||||||
- `AGENT_PROMOTED`
|
|
||||||
- `PROGENY_SIGNAL_CREATED` as a string event from `ProgenyService`
|
|
||||||
|
|
||||||
These are audit/event facts, not asynchronous orchestration triggers in the current implementation.
|
|
||||||
|
|
||||||
## Project DAG vs Execution Graph
|
|
||||||
|
|
||||||
### Project DAG
|
|
||||||
|
|
||||||
The Project DAG answers: what work exists, and what depends on what?
|
|
||||||
|
|
||||||
Current implementation:
|
|
||||||
|
|
||||||
- `ProjectPlanContract` represents milestones, features, tasks, and task dependency references.
|
|
||||||
- `validate_project_plan()` validates task dependency references and cycle freedom.
|
|
||||||
- `ProjectPlanBuilder.apply()` persists `ProjectPlan`, `Milestone`, `Feature`, `Task`, and `TaskDependency` rows.
|
|
||||||
- `TaskScheduler` consumes `TaskDependency` rows to claim only unblocked `READY` tasks.
|
|
||||||
|
|
||||||
This is real and production-relevant today.
|
|
||||||
|
|
||||||
### Execution Graph
|
|
||||||
|
|
||||||
The Execution Graph answers: how is one task/feature/milestone autonomously executed?
|
|
||||||
|
|
||||||
Current implementation:
|
|
||||||
|
|
||||||
- This graph is implicit in `AutonomousTaskLoop._execute_task()`.
|
|
||||||
- Nodes are not persisted as graph nodes.
|
|
||||||
- Edges are Python `if`/`continue`/`return` branches.
|
|
||||||
- Looping is a Python `while task.retry_count <= effective_max_retries`.
|
|
||||||
- Checkpointing is coarse-grained through Django rows such as `TaskAttempt`, `TestRun`, `Review`, `Verification`, and `CommitRecord`.
|
|
||||||
|
|
||||||
The execution graph exists conceptually but not as a first-class runtime artifact.
|
|
||||||
|
|
||||||
## CoderToolLoop Placement
|
|
||||||
|
|
||||||
Recommended placement: graph node containing an internal tool loop.
|
|
||||||
|
|
||||||
Do not make `CoderToolLoop` the top-level execution graph yet.
|
|
||||||
|
|
||||||
Rationale:
|
|
||||||
|
|
||||||
- `CoderToolLoop` is a bounded inner loop for a single agent/tool interaction.
|
|
||||||
- Its state is local and tactical: inspection results, mutation results, patch mismatch recovery, operation telemetry.
|
|
||||||
- It should be resumable eventually, but it is not the same graph as the whole task lifecycle.
|
|
||||||
- Making every tool call a top-level graph node now would increase orchestration complexity before there is enough operational pressure.
|
|
||||||
|
|
||||||
Near-term target:
|
|
||||||
|
|
||||||
- `TaskExecutionGraph` has a `coder` node.
|
|
||||||
- The `coder` node invokes `Coder.execute()`.
|
|
||||||
- `Coder.execute()` owns `CoderToolLoop` internally.
|
|
||||||
- `CoderToolLoop` emits structured telemetry and failure evidence that the outer graph records.
|
|
||||||
|
|
||||||
Later target:
|
|
||||||
|
|
||||||
- If long-running coding sessions, human approvals, streamed model/tool execution, or partial resume become necessary, promote internal tool-loop steps to a nested subgraph.
|
|
||||||
|
|
||||||
## Runtime Capability Assessment
|
|
||||||
|
|
||||||
### Conditional edges
|
|
||||||
|
|
||||||
Required.
|
|
||||||
|
|
||||||
Current conditional branches include:
|
|
||||||
|
|
||||||
- no ready task -> stop
|
|
||||||
- dependencies incomplete -> skip task
|
|
||||||
- coder failed -> retry/fail
|
|
||||||
- tests failed -> still Review, then retry/fail through Review
|
|
||||||
- reviewer failed -> retry/fail
|
|
||||||
- judge failed -> retry/fail
|
|
||||||
- all checks passed -> commit
|
|
||||||
|
|
||||||
These map naturally to graph conditional edges.
|
|
||||||
|
|
||||||
### Loops
|
|
||||||
|
|
||||||
Required.
|
|
||||||
|
|
||||||
Current loops include:
|
|
||||||
|
|
||||||
- task retry loop capped at three total attempts
|
|
||||||
- CoderToolLoop patch recovery loop
|
|
||||||
- future investigation and experiment loops
|
|
||||||
|
|
||||||
The retry cap must remain durable and enforced by Django state, not hidden only inside graph runtime memory.
|
|
||||||
|
|
||||||
### Parallel branches
|
|
||||||
|
|
||||||
Useful, not urgent for current V1/V2 guarantees.
|
|
||||||
|
|
||||||
Good candidates later:
|
|
||||||
|
|
||||||
- independent task execution across the Project DAG
|
|
||||||
- parallel tests/static checks/security checks
|
|
||||||
- multi-agent review lanes
|
|
||||||
- Progeny benchmark replay across candidates
|
|
||||||
|
|
||||||
Parallelism should be introduced only after worktree isolation and resource contention controls are explicit.
|
|
||||||
|
|
||||||
### Subgraphs
|
|
||||||
|
|
||||||
Required long-term.
|
|
||||||
|
|
||||||
Natural subgraphs:
|
|
||||||
|
|
||||||
- Task execution
|
|
||||||
- Coder tool loop
|
|
||||||
- Reviewer/Judge verification
|
|
||||||
- Progeny investigation
|
|
||||||
- Progeny experiment/champion-challenger evaluation
|
|
||||||
- Project Brain planning/replanning
|
|
||||||
|
|
||||||
### Checkpoint/resume
|
|
||||||
|
|
||||||
Required, but Django/Postgres should remain canonical.
|
|
||||||
|
|
||||||
Current checkpointing already exists at coarse boundaries:
|
|
||||||
|
|
||||||
- task claimed
|
|
||||||
- attempt created
|
|
||||||
- coder result saved
|
|
||||||
- test run saved
|
|
||||||
- review saved
|
|
||||||
- verification saved
|
|
||||||
- commit saved
|
|
||||||
|
|
||||||
Graph checkpointing should augment these records, not replace them.
|
|
||||||
|
|
||||||
### Human approval nodes
|
|
||||||
|
|
||||||
Required for safe V2+ autonomy.
|
|
||||||
|
|
||||||
Likely approval gates:
|
|
||||||
|
|
||||||
- project plan approval
|
|
||||||
- task split/replan approval
|
|
||||||
- risky migration approval
|
|
||||||
- provider credential/resource approval
|
|
||||||
- promotion of Progeny candidates
|
|
||||||
- destructive infrastructure changes
|
|
||||||
|
|
||||||
Human approval should persist as Django state and be represented as paused graph nodes.
|
|
||||||
|
|
||||||
### Dynamic temporary agent teams
|
|
||||||
|
|
||||||
Required later, not required for the current loop.
|
|
||||||
|
|
||||||
Smart Investigation and Progeny experiments will likely need temporary teams such as:
|
|
||||||
|
|
||||||
- incident analyst
|
|
||||||
- domain expert
|
|
||||||
- prompt/policy editor
|
|
||||||
- benchmark designer
|
|
||||||
- reviewer challenger
|
|
||||||
|
|
||||||
The runtime should support this as graph metadata and persisted `AgentRun`/future team records.
|
|
||||||
|
|
||||||
### Graph versioning
|
|
||||||
|
|
||||||
Required before graph-based production execution.
|
|
||||||
|
|
||||||
Every accepted commit and Progeny investigation should know which execution graph version produced it. Without this, regressions in orchestration will be hard to diagnose.
|
|
||||||
|
|
||||||
### Graph telemetry
|
|
||||||
|
|
||||||
Required.
|
|
||||||
|
|
||||||
Existing telemetry should become graph-node telemetry:
|
|
||||||
|
|
||||||
- mutation operations
|
|
||||||
- patch success/mismatch rates
|
|
||||||
- write-file fallback rate
|
|
||||||
- model requests per task
|
|
||||||
- test duration/status
|
|
||||||
- review findings
|
|
||||||
- judge evidence
|
|
||||||
- retry count
|
|
||||||
- provider errors
|
|
||||||
- signal counts
|
|
||||||
|
|
||||||
This telemetry should feed Progeny.
|
|
||||||
|
|
||||||
### Progeny champion/challenger workflow graphs
|
|
||||||
|
|
||||||
Required for the future Replay Arena / experiment execution layer, but should not be the next refactor.
|
|
||||||
|
|
||||||
The Progeny workflow graph should model:
|
|
||||||
|
|
||||||
- investigation selection
|
|
||||||
- candidate generation
|
|
||||||
- benchmark construction
|
|
||||||
- champion/challenger replay
|
|
||||||
- judge/evaluator comparison
|
|
||||||
- approval/promotion/rejection
|
|
||||||
|
|
||||||
It should reuse the same runtime boundary but have separate graph versions from normal task execution.
|
|
||||||
|
|
||||||
## Where LangGraph Adds Material Value
|
|
||||||
|
|
||||||
LangGraph is likely valuable when Artifex needs durable, inspectable, conditional, resumable multi-agent workflows with nested loops.
|
|
||||||
|
|
||||||
Material-value areas:
|
|
||||||
|
|
||||||
- Explicit task execution graph with conditional retry/fail/commit branches.
|
|
||||||
- Smart Investigation graph: signals -> clusters -> hypotheses -> routing -> candidates.
|
|
||||||
- Human approval gates with pause/resume semantics.
|
|
||||||
- Progeny champion/challenger workflows.
|
|
||||||
- Dynamic temporary agent-team orchestration.
|
|
||||||
- Future parallel branches for tests/reviews/replays.
|
|
||||||
- Graph-level observability and visual traceability.
|
|
||||||
|
|
||||||
Low-value or risky areas for LangGraph right now:
|
|
||||||
|
|
||||||
- Replacing `TaskScheduler` as the canonical Project DAG scheduler.
|
|
||||||
- Replacing Django/Postgres state transitions.
|
|
||||||
- Turning every small Python helper call into a graph node.
|
|
||||||
- Moving worktree mutation safety into graph runtime abstractions.
|
|
||||||
- Hiding retry counters/checkpoints inside LangGraph-only state.
|
|
||||||
|
|
||||||
## Where Django/Postgres Should Remain Canonical
|
|
||||||
|
|
||||||
Django/Postgres should remain the source of truth for:
|
|
||||||
|
|
||||||
- Projects, plans, milestones, features, tasks, and task dependencies.
|
|
||||||
- Task statuses and retry counts.
|
|
||||||
- Worktrees and commit records.
|
|
||||||
- Attempts, coder results, reviews, judge verifications, test runs, artifacts.
|
|
||||||
- Events.
|
|
||||||
- Agent versions and promotion state.
|
|
||||||
- Progeny signals, investigations, and improvement candidates.
|
|
||||||
- Human approvals and audit history.
|
|
||||||
|
|
||||||
LangGraph, if introduced, should read/write through these canonical records rather than owning hidden state.
|
|
||||||
|
|
||||||
## Migration Path
|
|
||||||
|
|
||||||
### Phase 0: Preserve current behavior
|
|
||||||
|
|
||||||
Do not route production task execution through `LangGraphRuntime` yet.
|
|
||||||
|
|
||||||
Current guarantees to preserve:
|
|
||||||
|
|
||||||
- M2 autonomous execution
|
|
||||||
- M3 planning
|
|
||||||
- M4 Archaeologist
|
|
||||||
- M5 Agent Registry / Progeny
|
|
||||||
- M6 self-bootstrap
|
|
||||||
- CoderToolLoop
|
|
||||||
- worktree isolation
|
|
||||||
- inspect-before-edit
|
|
||||||
- safe mutation tools
|
|
||||||
- Reviewer/Judge separation
|
|
||||||
- deterministic tests
|
|
||||||
- three-attempt retry cap
|
|
||||||
- Sol/Qwen provider boundaries
|
|
||||||
- secret isolation
|
|
||||||
|
|
||||||
### Phase 1: Document the implicit execution graph
|
|
||||||
|
|
||||||
Add a declarative graph spec that mirrors current `AutonomousTaskLoop` behavior without executing it yet.
|
|
||||||
|
|
||||||
Suggested nodes:
|
|
||||||
|
|
||||||
- `claim_task`
|
|
||||||
- `prepare_worktree`
|
|
||||||
- `build_context`
|
|
||||||
- `coder`
|
|
||||||
- `run_tests`
|
|
||||||
- `review`
|
|
||||||
- `judge`
|
|
||||||
- `commit`
|
|
||||||
- `retry_or_fail`
|
|
||||||
- `cleanup`
|
|
||||||
|
|
||||||
Suggested conditional edges:
|
|
||||||
|
|
||||||
- `coder.failed -> retry_or_fail`
|
|
||||||
- `tests.failed -> review`
|
|
||||||
- `review.failed -> retry_or_fail`
|
|
||||||
- `judge.failed -> retry_or_fail`
|
|
||||||
- `judge.passed -> commit`
|
|
||||||
- `retry.available -> build_context`
|
|
||||||
- `retry.exhausted -> fail`
|
|
||||||
|
|
||||||
### Phase 2: Add graph run telemetry around the existing loop
|
|
||||||
|
|
||||||
Add persisted graph-run/node-run records or event payloads while keeping direct Python execution.
|
|
||||||
|
|
||||||
This gives observability before changing orchestration.
|
|
||||||
|
|
||||||
### Phase 3: Introduce `TaskExecutionRuntime` behind `GraphRuntime`
|
|
||||||
|
|
||||||
Implement a runtime adapter that can execute the graph spec using the existing service methods.
|
|
||||||
|
|
||||||
At this stage, `AutonomousTaskLoop` can delegate to the runtime but Django state remains canonical.
|
|
||||||
|
|
||||||
### Phase 4: Evaluate LangGraph as one adapter
|
|
||||||
|
|
||||||
Only after the explicit graph spec and telemetry exist, wire `LangGraphRuntime` as an implementation option.
|
|
||||||
|
|
||||||
Acceptance criteria should require deterministic parity with the current loop.
|
|
||||||
|
|
||||||
### Phase 5: Add subgraphs and human approval nodes
|
|
||||||
|
|
||||||
Promote Progeny investigation and champion/challenger replay workflows into graph subgraphs.
|
|
||||||
|
|
||||||
Add explicit approval nodes for candidate promotion and risky changes.
|
|
||||||
|
|
||||||
## Current Architecture Risks
|
|
||||||
|
|
||||||
1. `GraphRuntime` naming implies production graph execution, but it is currently a placeholder.
|
|
||||||
|
|
||||||
Risk: future contributors may assume LangGraph is already in the hot path.
|
|
||||||
|
|
||||||
2. Execution graph is implicit in Python.
|
|
||||||
|
|
||||||
Risk: retry and failure behavior becomes harder to evolve as Progeny wiring, investigation, and human gates are added.
|
|
||||||
|
|
||||||
3. Event Bus is persisted but not orchestration-driving.
|
|
||||||
|
|
||||||
Risk: event names may be interpreted as workflow triggers when they are currently audit facts.
|
|
||||||
|
|
||||||
4. `CoderToolLoop` has internal iteration but only coarse persistence.
|
|
||||||
|
|
||||||
Risk: power loss during model/tool-loop execution resumes only at task-attempt granularity, not tool-step granularity.
|
|
||||||
|
|
||||||
5. Reviewer/Judge are deterministic services, not model-driven graph nodes.
|
|
||||||
|
|
||||||
Risk: current separation is good, but future multi-agent review designs need explicit graph boundaries.
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
|
|
||||||
Do not refactor execution into LangGraph immediately.
|
|
||||||
|
|
||||||
The safest next step is to keep the existing Python/Django loop as the production engine and create a first-class, documented `TaskExecutionGraph` specification plus telemetry records around it. This preserves all working guarantees while making the intended graph architecture concrete.
|
|
||||||
|
|
||||||
CoderToolLoop should remain an internal loop inside the Coder graph node for now. Promote it to a subgraph only when resumability or multi-step observability at each tool call becomes necessary.
|
|
||||||
|
|
||||||
LangGraph should be introduced as an adapter behind `GraphRuntime` only after deterministic parity tests prove it reproduces the current loop exactly.
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class GraphConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "graph"
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from graph.models import ExecutionGraphDefinition, ExecutionGraphVersion, ExecutionGraphVersionStatus
|
|
||||||
from graph.task_execution import task_execution_graph_v1
|
|
||||||
|
|
||||||
|
|
||||||
def champion_task_execution_graph_v1() -> ExecutionGraphVersion:
|
|
||||||
spec = task_execution_graph_v1()
|
|
||||||
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
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from graph.models import GraphRun
|
|
||||||
|
|
||||||
|
|
||||||
def graph_run_inspection(graph_run: GraphRun) -> dict[str, object]:
|
|
||||||
spec = graph_run.execution_graph_version.graph_spec
|
|
||||||
node_runs = {
|
|
||||||
node.node_id: node
|
|
||||||
for node in graph_run.node_runs.order_by("node_id", "-visit_index")
|
|
||||||
}
|
|
||||||
nodes = []
|
|
||||||
for node_id, node_spec in spec.get("nodes", {}).items():
|
|
||||||
run = node_runs.get(node_id)
|
|
||||||
nodes.append(
|
|
||||||
{
|
|
||||||
"id": node_id,
|
|
||||||
"type": node_spec.get("type"),
|
|
||||||
"status": run.status if run else "PENDING",
|
|
||||||
"visit_index": run.visit_index if run else 0,
|
|
||||||
"duration_ms": (run.telemetry or {}).get("duration_ms") if run else None,
|
|
||||||
"failure": run.failure_evidence if run else {},
|
|
||||||
"metadata": node_spec.get("metadata", {}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"graph": graph_run.execution_graph_version.graph.name,
|
|
||||||
"version": graph_run.execution_graph_version.version,
|
|
||||||
"status": graph_run.status,
|
|
||||||
"current_node": graph_run.current_node,
|
|
||||||
"nodes": nodes,
|
|
||||||
"edges": spec.get("edges", []),
|
|
||||||
"edge_traversals": list(
|
|
||||||
graph_run.edge_traversals.order_by("created_at").values("source_node", "target_node", "condition", "result", "metadata", "created_at")
|
|
||||||
),
|
|
||||||
"failures": list(graph_run.node_runs.exclude(failure_evidence={}).values("node_id", "failure_evidence")),
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +1,29 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, TypedDict
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from graph.models import GraphRun
|
|
||||||
from graph.native_runtime import NativeGraphRuntime
|
|
||||||
from graph.registry import NodeHandlerRegistry
|
|
||||||
from graph.runtime import GraphRuntime
|
from graph.runtime import GraphRuntime
|
||||||
from graph.spec import ExecutionGraphSpec
|
|
||||||
|
|
||||||
|
|
||||||
class LangGraphState(TypedDict, total=False):
|
|
||||||
graph_run_id: int
|
|
||||||
current_node: str
|
|
||||||
status: str
|
|
||||||
edge_result: str
|
|
||||||
|
|
||||||
|
|
||||||
class LangGraphRuntime(GraphRuntime):
|
class LangGraphRuntime(GraphRuntime):
|
||||||
"""LangGraph adapter behind Artifex's runtime-neutral graph boundary."""
|
"""Initial LangGraph adapter placeholder.
|
||||||
|
|
||||||
def __init__(self, registry: NodeHandlerRegistry | None = None) -> None:
|
M1 keeps this deterministic. M2 will wire the autonomous task loop here while
|
||||||
self.registry = registry
|
preserving this boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
async def start(self, project_id: UUID | None = None, **kwargs: Any) -> str:
|
async def start(self, project_id: UUID) -> str:
|
||||||
graph_run = kwargs.get("graph_run")
|
|
||||||
if graph_run is not None:
|
|
||||||
self.run_until_terminal_or_paused(graph_run)
|
|
||||||
return str(graph_run.id)
|
|
||||||
return f"project-{project_id}"
|
return f"project-{project_id}"
|
||||||
|
|
||||||
async def pause(self, run_id: str) -> None:
|
async def pause(self, run_id: str) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def resume(self, run_id: str) -> None:
|
async def resume(self, run_id: str) -> None:
|
||||||
if self.registry is None:
|
return None
|
||||||
return None
|
|
||||||
graph_run = GraphRun.objects.get(id=run_id)
|
|
||||||
self.run_until_terminal_or_paused(graph_run)
|
|
||||||
|
|
||||||
async def cancel(self, run_id: str) -> None:
|
async def cancel(self, run_id: str) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def signal(self, run_id: str, event: dict[str, Any]) -> None:
|
async def signal(self, run_id: str, event: dict[str, Any]) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def run_until_terminal_or_paused(self, graph_run: GraphRun) -> GraphRun:
|
|
||||||
if self.registry is None:
|
|
||||||
raise RuntimeError("LangGraphRuntime requires a NodeHandlerRegistry")
|
|
||||||
try:
|
|
||||||
from langgraph.graph import END, StateGraph
|
|
||||||
except ImportError as exc:
|
|
||||||
raise RuntimeError("LangGraphRuntime requires the langgraph package") from exc
|
|
||||||
|
|
||||||
spec = ExecutionGraphSpec.from_dict(graph_run.execution_graph_version.graph_spec)
|
|
||||||
native = NativeGraphRuntime(self.registry)
|
|
||||||
|
|
||||||
workflow = StateGraph(LangGraphState)
|
|
||||||
for node_id in spec.nodes:
|
|
||||||
workflow.add_node(node_id, self._node_runner(native, graph_run, node_id))
|
|
||||||
workflow.set_entry_point(graph_run.current_node or spec.entry)
|
|
||||||
for node_id in spec.nodes:
|
|
||||||
if node_id in spec.terminal_nodes:
|
|
||||||
workflow.add_edge(node_id, END)
|
|
||||||
continue
|
|
||||||
edges = [edge for edge in spec.edges if edge.source == node_id]
|
|
||||||
if not edges:
|
|
||||||
workflow.add_edge(node_id, END)
|
|
||||||
continue
|
|
||||||
workflow.add_conditional_edges(
|
|
||||||
node_id,
|
|
||||||
lambda state: str(state.get("edge_result", "success")),
|
|
||||||
{edge.condition or "success": edge.target for edge in edges},
|
|
||||||
)
|
|
||||||
compiled = workflow.compile()
|
|
||||||
compiled.invoke({"graph_run_id": graph_run.id, "current_node": graph_run.current_node or spec.entry})
|
|
||||||
graph_run.refresh_from_db()
|
|
||||||
return graph_run
|
|
||||||
|
|
||||||
def _node_runner(self, native: NativeGraphRuntime, graph_run: GraphRun, node_id: str):
|
|
||||||
def run_node(state: LangGraphState) -> LangGraphState:
|
|
||||||
if graph_run.current_node != node_id:
|
|
||||||
graph_run.current_node = node_id
|
|
||||||
graph_run.save(update_fields=["current_node", "updated_at"])
|
|
||||||
native.run_until_terminal_or_paused(graph_run, interrupt_after=node_id)
|
|
||||||
graph_run.refresh_from_db()
|
|
||||||
return {
|
|
||||||
"graph_run_id": graph_run.id,
|
|
||||||
"current_node": graph_run.current_node,
|
|
||||||
"status": graph_run.status,
|
|
||||||
"edge_result": str(graph_run.metadata.get("last_edge_result", "success")),
|
|
||||||
}
|
|
||||||
|
|
||||||
return run_node
|
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("agents", "0004_progenysignal"),
|
|
||||||
("projects", "0002_commitrecord_coder_commitrecord_judge_and_more"),
|
|
||||||
("resources", "0002_modelrequest_completion_tokens_and_more"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="ExecutionGraphDefinition",
|
|
||||||
fields=[
|
|
||||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
||||||
("name", models.CharField(max_length=160, unique=True)),
|
|
||||||
("graph_type", models.CharField(max_length=80)),
|
|
||||||
("description", models.TextField(blank=True)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="ExecutionGraphVersion",
|
|
||||||
fields=[
|
|
||||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
||||||
("version", models.PositiveIntegerField()),
|
|
||||||
("status", models.CharField(choices=[("DRAFT", "Draft"), ("CHALLENGER", "Challenger"), ("CHAMPION", "Champion"), ("RETIRED", "Retired")], default="DRAFT", max_length=32)),
|
|
||||||
("graph_spec", models.JSONField(default=dict)),
|
|
||||||
("metadata", models.JSONField(blank=True, default=dict)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("promoted_at", models.DateTimeField(blank=True, null=True)),
|
|
||||||
("graph", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="versions", to="graph.executiongraphdefinition")),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="GraphRun",
|
|
||||||
fields=[
|
|
||||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
||||||
("status", models.CharField(choices=[("PENDING", "Pending"), ("RUNNING", "Running"), ("PAUSED", "Paused"), ("COMPLETE", "Complete"), ("FAILED", "Failed"), ("CANCELLED", "Cancelled")], default="PENDING", max_length=32)),
|
|
||||||
("started_at", models.DateTimeField(blank=True, null=True)),
|
|
||||||
("completed_at", models.DateTimeField(blank=True, null=True)),
|
|
||||||
("current_node", models.CharField(blank=True, max_length=120)),
|
|
||||||
("failure_reason", models.TextField(blank=True)),
|
|
||||||
("metadata", models.JSONField(blank=True, default=dict)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
|
||||||
("execution_graph_version", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="runs", to="graph.executiongraphversion")),
|
|
||||||
("feature", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="graph_runs", to="projects.feature")),
|
|
||||||
("milestone", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="graph_runs", to="projects.milestone")),
|
|
||||||
("project", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="graph_runs", to="projects.project")),
|
|
||||||
("task", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="graph_runs", to="projects.task")),
|
|
||||||
("task_attempt", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="graph_runs", to="projects.taskattempt")),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="GraphNodeRun",
|
|
||||||
fields=[
|
|
||||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
||||||
("node_id", models.CharField(max_length=120)),
|
|
||||||
("node_type", models.CharField(max_length=120)),
|
|
||||||
("status", models.CharField(choices=[("PENDING", "Pending"), ("RUNNING", "Running"), ("PAUSED", "Paused"), ("COMPLETE", "Complete"), ("FAILED", "Failed"), ("SKIPPED", "Skipped")], default="PENDING", max_length=32)),
|
|
||||||
("started_at", models.DateTimeField(blank=True, null=True)),
|
|
||||||
("completed_at", models.DateTimeField(blank=True, null=True)),
|
|
||||||
("input_metadata", models.JSONField(blank=True, default=dict)),
|
|
||||||
("output_metadata", models.JSONField(blank=True, default=dict)),
|
|
||||||
("failure_evidence", models.JSONField(blank=True, default=dict)),
|
|
||||||
("telemetry", models.JSONField(blank=True, default=dict)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
|
||||||
("agent_version", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="graph_node_runs", to="agents.agentversion")),
|
|
||||||
("graph_run", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="node_runs", to="graph.graphrun")),
|
|
||||||
("model_request", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="graph_node_runs", to="resources.modelrequest")),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="GraphEdgeTraversal",
|
|
||||||
fields=[
|
|
||||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
||||||
("source_node", models.CharField(max_length=120)),
|
|
||||||
("target_node", models.CharField(max_length=120)),
|
|
||||||
("condition", models.CharField(blank=True, max_length=120)),
|
|
||||||
("result", models.CharField(blank=True, max_length=120)),
|
|
||||||
("metadata", models.JSONField(blank=True, default=dict)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("graph_run", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="edge_traversals", to="graph.graphrun")),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(model_name="executiongraphversion", constraint=models.UniqueConstraint(fields=("graph", "version"), name="unique_execution_graph_version")),
|
|
||||||
migrations.AddConstraint(model_name="graphnoderun", constraint=models.UniqueConstraint(fields=("graph_run", "node_id"), name="unique_graph_node_run")),
|
|
||||||
migrations.AddIndex(model_name="graphrun", index=models.Index(fields=["status", "created_at"], name="graph_graph_status_11d04c_idx")),
|
|
||||||
migrations.AddIndex(model_name="graphrun", index=models.Index(fields=["task", "status"], name="graph_graph_task_id_c7ed9d_idx")),
|
|
||||||
migrations.AddIndex(model_name="graphnoderun", index=models.Index(fields=["node_id", "status"], name="graph_graph_node_id_43f921_idx")),
|
|
||||||
migrations.AddIndex(model_name="graphedgetraversal", index=models.Index(fields=["graph_run", "created_at"], name="graph_graph_graph_r_42d7dc_idx")),
|
|
||||||
]
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("graph", "0001_initial"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveConstraint(model_name="graphnoderun", name="unique_graph_node_run"),
|
|
||||||
migrations.AddField(model_name="graphnoderun", name="visit_index", field=models.PositiveIntegerField(default=1)),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name="graphnoderun",
|
|
||||||
constraint=models.UniqueConstraint(fields=("graph_run", "node_id", "visit_index"), name="unique_graph_node_run_visit"),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("graph", "0002_graphnoderun_visit_index"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="GraphApproval",
|
|
||||||
fields=[
|
|
||||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
||||||
("status", models.CharField(choices=[("PENDING", "Pending"), ("APPROVED", "Approved"), ("REJECTED", "Rejected")], default="PENDING", max_length=32)),
|
|
||||||
("reason", models.CharField(max_length=160)),
|
|
||||||
("payload", models.JSONField(blank=True, default=dict)),
|
|
||||||
("requested_by", models.CharField(default="graph_runtime", max_length=120)),
|
|
||||||
("decided_by", models.CharField(blank=True, max_length=120)),
|
|
||||||
("decided_at", models.DateTimeField(blank=True, null=True)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
|
||||||
("graph_run", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="approvals", to="graph.graphrun")),
|
|
||||||
("node_run", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="approvals", to="graph.graphnoderun")),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
129
graph/models.py
129
graph/models.py
|
|
@ -1,129 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class ExecutionGraphVersionStatus(models.TextChoices):
|
|
||||||
DRAFT = "DRAFT"
|
|
||||||
CHALLENGER = "CHALLENGER"
|
|
||||||
CHAMPION = "CHAMPION"
|
|
||||||
RETIRED = "RETIRED"
|
|
||||||
|
|
||||||
|
|
||||||
class GraphRunStatus(models.TextChoices):
|
|
||||||
PENDING = "PENDING"
|
|
||||||
RUNNING = "RUNNING"
|
|
||||||
PAUSED = "PAUSED"
|
|
||||||
COMPLETE = "COMPLETE"
|
|
||||||
FAILED = "FAILED"
|
|
||||||
CANCELLED = "CANCELLED"
|
|
||||||
|
|
||||||
|
|
||||||
class GraphNodeRunStatus(models.TextChoices):
|
|
||||||
PENDING = "PENDING"
|
|
||||||
RUNNING = "RUNNING"
|
|
||||||
PAUSED = "PAUSED"
|
|
||||||
COMPLETE = "COMPLETE"
|
|
||||||
FAILED = "FAILED"
|
|
||||||
SKIPPED = "SKIPPED"
|
|
||||||
|
|
||||||
|
|
||||||
class ExecutionGraphDefinition(models.Model):
|
|
||||||
name = models.CharField(max_length=160, unique=True)
|
|
||||||
graph_type = models.CharField(max_length=80)
|
|
||||||
description = models.TextField(blank=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
|
|
||||||
class ExecutionGraphVersion(models.Model):
|
|
||||||
graph = models.ForeignKey(ExecutionGraphDefinition, on_delete=models.CASCADE, related_name="versions")
|
|
||||||
version = models.PositiveIntegerField()
|
|
||||||
status = models.CharField(max_length=32, choices=ExecutionGraphVersionStatus.choices, default=ExecutionGraphVersionStatus.DRAFT)
|
|
||||||
graph_spec = models.JSONField(default=dict)
|
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
promoted_at = models.DateTimeField(null=True, blank=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
constraints = [models.UniqueConstraint(fields=["graph", "version"], name="unique_execution_graph_version")]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.graph.name} v{self.version}"
|
|
||||||
|
|
||||||
|
|
||||||
class GraphRun(models.Model):
|
|
||||||
execution_graph_version = models.ForeignKey(ExecutionGraphVersion, on_delete=models.PROTECT, related_name="runs")
|
|
||||||
project = models.ForeignKey("projects.Project", on_delete=models.CASCADE, null=True, blank=True, related_name="graph_runs")
|
|
||||||
milestone = models.ForeignKey("projects.Milestone", on_delete=models.SET_NULL, null=True, blank=True, related_name="graph_runs")
|
|
||||||
feature = models.ForeignKey("projects.Feature", on_delete=models.SET_NULL, null=True, blank=True, related_name="graph_runs")
|
|
||||||
task = models.ForeignKey("projects.Task", on_delete=models.SET_NULL, null=True, blank=True, related_name="graph_runs")
|
|
||||||
task_attempt = models.ForeignKey("projects.TaskAttempt", on_delete=models.SET_NULL, null=True, blank=True, related_name="graph_runs")
|
|
||||||
status = models.CharField(max_length=32, choices=GraphRunStatus.choices, default=GraphRunStatus.PENDING)
|
|
||||||
started_at = models.DateTimeField(null=True, blank=True)
|
|
||||||
completed_at = models.DateTimeField(null=True, blank=True)
|
|
||||||
current_node = models.CharField(max_length=120, blank=True)
|
|
||||||
failure_reason = models.TextField(blank=True)
|
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
indexes = [models.Index(fields=["status", "created_at"]), models.Index(fields=["task", "status"])]
|
|
||||||
|
|
||||||
|
|
||||||
class GraphNodeRun(models.Model):
|
|
||||||
graph_run = models.ForeignKey(GraphRun, on_delete=models.CASCADE, related_name="node_runs")
|
|
||||||
node_id = models.CharField(max_length=120)
|
|
||||||
node_type = models.CharField(max_length=120)
|
|
||||||
visit_index = models.PositiveIntegerField(default=1)
|
|
||||||
status = models.CharField(max_length=32, choices=GraphNodeRunStatus.choices, default=GraphNodeRunStatus.PENDING)
|
|
||||||
started_at = models.DateTimeField(null=True, blank=True)
|
|
||||||
completed_at = models.DateTimeField(null=True, blank=True)
|
|
||||||
agent_version = models.ForeignKey("agents.AgentVersion", on_delete=models.SET_NULL, null=True, blank=True, related_name="graph_node_runs")
|
|
||||||
model_request = models.ForeignKey("resources.ModelRequest", on_delete=models.SET_NULL, null=True, blank=True, related_name="graph_node_runs")
|
|
||||||
input_metadata = models.JSONField(default=dict, blank=True)
|
|
||||||
output_metadata = models.JSONField(default=dict, blank=True)
|
|
||||||
failure_evidence = models.JSONField(default=dict, blank=True)
|
|
||||||
telemetry = models.JSONField(default=dict, blank=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
constraints = [models.UniqueConstraint(fields=["graph_run", "node_id", "visit_index"], name="unique_graph_node_run_visit")]
|
|
||||||
indexes = [models.Index(fields=["node_id", "status"])]
|
|
||||||
|
|
||||||
|
|
||||||
class GraphEdgeTraversal(models.Model):
|
|
||||||
graph_run = models.ForeignKey(GraphRun, on_delete=models.CASCADE, related_name="edge_traversals")
|
|
||||||
source_node = models.CharField(max_length=120)
|
|
||||||
target_node = models.CharField(max_length=120)
|
|
||||||
condition = models.CharField(max_length=120, blank=True)
|
|
||||||
result = models.CharField(max_length=120, blank=True)
|
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
indexes = [models.Index(fields=["graph_run", "created_at"])]
|
|
||||||
|
|
||||||
|
|
||||||
class GraphApprovalStatus(models.TextChoices):
|
|
||||||
PENDING = "PENDING"
|
|
||||||
APPROVED = "APPROVED"
|
|
||||||
REJECTED = "REJECTED"
|
|
||||||
|
|
||||||
|
|
||||||
class GraphApproval(models.Model):
|
|
||||||
graph_run = models.ForeignKey(GraphRun, on_delete=models.CASCADE, related_name="approvals")
|
|
||||||
node_run = models.ForeignKey(GraphNodeRun, on_delete=models.CASCADE, null=True, blank=True, related_name="approvals")
|
|
||||||
status = models.CharField(max_length=32, choices=GraphApprovalStatus.choices, default=GraphApprovalStatus.PENDING)
|
|
||||||
reason = models.CharField(max_length=160)
|
|
||||||
payload = models.JSONField(default=dict, blank=True)
|
|
||||||
requested_by = models.CharField(max_length=120, default="graph_runtime")
|
|
||||||
decided_by = models.CharField(max_length=120, blank=True)
|
|
||||||
decided_at = models.DateTimeField(null=True, blank=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from asgiref.sync import sync_to_async
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from control_plane.events.bus import EventBus
|
|
||||||
from graph.models import GraphApprovalStatus, GraphEdgeTraversal, GraphNodeRun, GraphNodeRunStatus, GraphRun, GraphRunStatus
|
|
||||||
from graph.registry import NodeHandlerRegistry, NodeResult
|
|
||||||
from graph.runtime import GraphRuntime
|
|
||||||
from graph.spec import ExecutionGraphSpec, outgoing_edges
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GraphExecutionContext:
|
|
||||||
graph_run: GraphRun
|
|
||||||
spec: ExecutionGraphSpec
|
|
||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
|
||||||
signal_payload: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class NativeGraphRuntime(GraphRuntime):
|
|
||||||
def __init__(self, registry: NodeHandlerRegistry, bus: EventBus | None = None) -> None:
|
|
||||||
self.registry = registry
|
|
||||||
self.bus = bus or EventBus()
|
|
||||||
|
|
||||||
async def start(self, project_id: UUID | None = None, *, graph_run: GraphRun | None = None) -> str:
|
|
||||||
if graph_run is None:
|
|
||||||
raise ValueError("NativeGraphRuntime.start requires a persisted GraphRun")
|
|
||||||
if graph_run.status == GraphRunStatus.PENDING:
|
|
||||||
graph_run.status = GraphRunStatus.RUNNING
|
|
||||||
graph_run.started_at = timezone.now()
|
|
||||||
graph_run.current_node = graph_run.current_node or graph_run.execution_graph_version.graph_spec["entry"]
|
|
||||||
graph_run.save(update_fields=["status", "started_at", "current_node", "updated_at"])
|
|
||||||
self.bus.publish("GRAPH_RUN_STARTED", project=graph_run.project, task=graph_run.task, payload={"graph_run_id": graph_run.id})
|
|
||||||
return str(graph_run.id)
|
|
||||||
|
|
||||||
async def pause(self, run_id: str) -> None:
|
|
||||||
graph_run = GraphRun.objects.get(id=run_id)
|
|
||||||
graph_run.status = GraphRunStatus.PAUSED
|
|
||||||
graph_run.save(update_fields=["status", "updated_at"])
|
|
||||||
self.bus.publish("GRAPH_RUN_PAUSED", project=graph_run.project, task=graph_run.task, payload={"graph_run_id": graph_run.id})
|
|
||||||
|
|
||||||
async def resume(self, run_id: str) -> None:
|
|
||||||
graph_run = GraphRun.objects.get(id=run_id)
|
|
||||||
if graph_run.status == GraphRunStatus.PAUSED:
|
|
||||||
graph_run.status = GraphRunStatus.RUNNING
|
|
||||||
graph_run.save(update_fields=["status", "updated_at"])
|
|
||||||
self.bus.publish("GRAPH_RUN_RESUMED", project=graph_run.project, task=graph_run.task, payload={"graph_run_id": graph_run.id})
|
|
||||||
self.run_until_terminal_or_paused(graph_run)
|
|
||||||
|
|
||||||
async def cancel(self, run_id: str) -> None:
|
|
||||||
graph_run = GraphRun.objects.get(id=run_id)
|
|
||||||
graph_run.status = GraphRunStatus.CANCELLED
|
|
||||||
graph_run.completed_at = timezone.now()
|
|
||||||
graph_run.save(update_fields=["status", "completed_at", "updated_at"])
|
|
||||||
self.bus.publish("GRAPH_RUN_CANCELLED", project=graph_run.project, task=graph_run.task, payload={"graph_run_id": graph_run.id})
|
|
||||||
|
|
||||||
async def signal(self, run_id: str, event: dict[str, Any]) -> None:
|
|
||||||
await sync_to_async(self.signal_now)(run_id, event)
|
|
||||||
|
|
||||||
def signal_now(self, run_id: str, event: dict[str, Any]) -> None:
|
|
||||||
graph_run = GraphRun.objects.get(id=run_id)
|
|
||||||
metadata = dict(graph_run.metadata)
|
|
||||||
metadata["last_signal"] = self._bounded(event)
|
|
||||||
if event.get("action") == "approve" and graph_run.status == GraphRunStatus.PAUSED:
|
|
||||||
for approval in graph_run.approvals.filter(status=GraphApprovalStatus.PENDING):
|
|
||||||
approval.status = GraphApprovalStatus.APPROVED
|
|
||||||
approval.decided_by = str(event.get("actor", "system"))
|
|
||||||
approval.decided_at = timezone.now()
|
|
||||||
approval.save(update_fields=["status", "decided_by", "decided_at", "updated_at"])
|
|
||||||
graph_run.status = GraphRunStatus.RUNNING
|
|
||||||
graph_run.metadata = metadata
|
|
||||||
graph_run.save(update_fields=["metadata", "status", "updated_at"])
|
|
||||||
self.bus.publish("GRAPH_RUN_SIGNALED", project=graph_run.project, task=graph_run.task, payload={"graph_run_id": graph_run.id, "signal": self._bounded(event)})
|
|
||||||
|
|
||||||
def run_until_terminal_or_paused(self, graph_run: GraphRun, *, interrupt_after: str | None = None) -> GraphRun:
|
|
||||||
graph_run.refresh_from_db()
|
|
||||||
spec = ExecutionGraphSpec.from_dict(graph_run.execution_graph_version.graph_spec)
|
|
||||||
spec.validate()
|
|
||||||
if graph_run.status == GraphRunStatus.PENDING:
|
|
||||||
graph_run.status = GraphRunStatus.RUNNING
|
|
||||||
graph_run.started_at = timezone.now()
|
|
||||||
graph_run.current_node = graph_run.current_node or spec.entry
|
|
||||||
graph_run.save(update_fields=["status", "started_at", "current_node", "updated_at"])
|
|
||||||
self.bus.publish("GRAPH_RUN_STARTED", project=graph_run.project, task=graph_run.task, payload={"graph_run_id": graph_run.id})
|
|
||||||
while graph_run.status == GraphRunStatus.RUNNING:
|
|
||||||
node_id = graph_run.current_node or spec.entry
|
|
||||||
if node_id in spec.terminal_nodes:
|
|
||||||
self._finish_terminal(graph_run, node_id)
|
|
||||||
break
|
|
||||||
node_spec = spec.nodes[node_id]
|
|
||||||
context = GraphExecutionContext(graph_run=graph_run, spec=spec, metadata=dict(graph_run.metadata))
|
|
||||||
node_run, created = self._node_run(graph_run, node_id, node_spec.node_type)
|
|
||||||
if not created and node_run.status == GraphNodeRunStatus.COMPLETE:
|
|
||||||
result = NodeResult("COMPLETE", str(node_run.output_metadata.get("edge_result", "success")), node_run.output_metadata, node_run.telemetry)
|
|
||||||
else:
|
|
||||||
result = self._execute_node(context, node_run)
|
|
||||||
if interrupt_after == node_id:
|
|
||||||
graph_run.metadata = {**dict(graph_run.metadata), "interrupted_after_node": node_id}
|
|
||||||
graph_run.save(update_fields=["metadata", "updated_at"])
|
|
||||||
graph_run.refresh_from_db()
|
|
||||||
return graph_run
|
|
||||||
if result.status == "PAUSED":
|
|
||||||
graph_run.status = GraphRunStatus.PAUSED
|
|
||||||
graph_run.failure_reason = result.pause_reason
|
|
||||||
graph_run.save(update_fields=["status", "failure_reason", "updated_at"])
|
|
||||||
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":
|
|
||||||
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"])
|
|
||||||
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:
|
|
||||||
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"])
|
|
||||||
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)
|
|
||||||
self.bus.publish("GRAPH_EDGE_TRAVERSED", project=graph_run.project, task=graph_run.task, payload={"graph_run_id": graph_run.id, "source": node_id, "target": next_node, "condition": result.edge_result})
|
|
||||||
graph_run.current_node = next_node
|
|
||||||
graph_run.metadata = self._merge_summary(graph_run.metadata, result)
|
|
||||||
graph_run.metadata["last_node_id"] = node_id
|
|
||||||
graph_run.metadata["last_edge_result"] = result.edge_result
|
|
||||||
graph_run.metadata["last_output"] = self._bounded(result.output_metadata or {})
|
|
||||||
graph_run.metadata.pop("interrupted_after_node", None)
|
|
||||||
graph_run.save(update_fields=["current_node", "metadata", "updated_at"])
|
|
||||||
graph_run.refresh_from_db()
|
|
||||||
return graph_run
|
|
||||||
|
|
||||||
def _node_run(self, graph_run: GraphRun, node_id: str, node_type: str) -> tuple[GraphNodeRun, bool]:
|
|
||||||
last = graph_run.node_runs.filter(node_id=node_id).order_by("-visit_index").first()
|
|
||||||
if last is not None and last.status in {GraphNodeRunStatus.PENDING, GraphNodeRunStatus.RUNNING, GraphNodeRunStatus.PAUSED}:
|
|
||||||
return last, False
|
|
||||||
if last is not None and last.status == GraphNodeRunStatus.COMPLETE and graph_run.current_node == node_id and graph_run.metadata.get("interrupted_after_node") == node_id:
|
|
||||||
return last, False
|
|
||||||
visit_index = 1 if last is None else last.visit_index + 1
|
|
||||||
return GraphNodeRun.objects.create(graph_run=graph_run, node_id=node_id, node_type=node_type, visit_index=visit_index), True
|
|
||||||
|
|
||||||
def _execute_node(self, context: GraphExecutionContext, node_run: GraphNodeRun) -> NodeResult:
|
|
||||||
handler = self.registry.get(node_run.node_type)
|
|
||||||
started = time.monotonic()
|
|
||||||
node_run.status = GraphNodeRunStatus.RUNNING
|
|
||||||
node_run.started_at = timezone.now()
|
|
||||||
node_run.save(update_fields=["status", "started_at", "updated_at"])
|
|
||||||
self.bus.publish("GRAPH_NODE_STARTED", project=context.graph_run.project, task=context.graph_run.task, payload={"graph_run_id": context.graph_run.id, "node_id": node_run.node_id})
|
|
||||||
try:
|
|
||||||
result = handler.run(context)
|
|
||||||
except Exception as exc:
|
|
||||||
result = NodeResult("FAILED", "failure", failure_evidence={"reason": str(exc)})
|
|
||||||
node_run.status = result.status if result.status in GraphNodeRunStatus.values else GraphNodeRunStatus.FAILED
|
|
||||||
node_run.completed_at = timezone.now()
|
|
||||||
output = dict(result.output_metadata or {})
|
|
||||||
output["edge_result"] = result.edge_result
|
|
||||||
node_run.output_metadata = self._bounded(output)
|
|
||||||
telemetry = dict(result.telemetry or {})
|
|
||||||
telemetry["duration_ms"] = int((time.monotonic() - started) * 1000)
|
|
||||||
node_run.telemetry = self._bounded(telemetry)
|
|
||||||
node_run.failure_evidence = self._bounded(result.failure_evidence or {})
|
|
||||||
node_run.save(update_fields=["status", "completed_at", "output_metadata", "telemetry", "failure_evidence", "updated_at"])
|
|
||||||
event = "GRAPH_NODE_COMPLETED" if result.status == "COMPLETE" else "GRAPH_NODE_FAILED"
|
|
||||||
self.bus.publish(event, project=context.graph_run.project, task=context.graph_run.task, payload={"graph_run_id": context.graph_run.id, "node_id": node_run.node_id, "status": result.status})
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _select_next(self, spec: ExecutionGraphSpec, node_id: str, edge_result: str) -> str | None:
|
|
||||||
edges = outgoing_edges(spec, node_id)
|
|
||||||
for edge in edges:
|
|
||||||
if edge.condition == edge_result:
|
|
||||||
return edge.target
|
|
||||||
for edge in edges:
|
|
||||||
if not edge.condition:
|
|
||||||
return edge.target
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _finish_terminal(self, graph_run: GraphRun, node_id: str) -> None:
|
|
||||||
graph_run.status = GraphRunStatus.FAILED if node_id == "fail" else GraphRunStatus.COMPLETE
|
|
||||||
graph_run.completed_at = timezone.now()
|
|
||||||
graph_run.save(update_fields=["status", "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})
|
|
||||||
|
|
||||||
def _merge_summary(self, metadata: dict[str, Any], result: NodeResult) -> dict[str, Any]:
|
|
||||||
summary = dict(metadata)
|
|
||||||
telemetry = dict(summary.get("telemetry", {}))
|
|
||||||
for key, value in (result.telemetry or {}).items():
|
|
||||||
if isinstance(value, int | float):
|
|
||||||
telemetry[key] = telemetry.get(key, 0) + value
|
|
||||||
summary["telemetry"] = telemetry
|
|
||||||
return summary
|
|
||||||
|
|
||||||
def _bounded(self, value: Any) -> Any:
|
|
||||||
text = str(value)
|
|
||||||
if len(text) > 20000:
|
|
||||||
return {"truncated": True, "excerpt": text[:20000]}
|
|
||||||
return value
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class NodeResult:
|
|
||||||
status: str
|
|
||||||
edge_result: str = "success"
|
|
||||||
output_metadata: dict[str, object] | None = None
|
|
||||||
telemetry: dict[str, object] | None = None
|
|
||||||
failure_evidence: dict[str, object] | None = None
|
|
||||||
pause_reason: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class NodeHandler(Protocol):
|
|
||||||
node_type: str
|
|
||||||
idempotent: bool
|
|
||||||
replay_safe: bool
|
|
||||||
destructive: bool
|
|
||||||
|
|
||||||
def run(self, context: object) -> NodeResult: ...
|
|
||||||
|
|
||||||
|
|
||||||
class NodeHandlerRegistry:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._handlers: dict[str, NodeHandler] = {}
|
|
||||||
|
|
||||||
def register(self, handler: NodeHandler) -> None:
|
|
||||||
self._handlers[handler.node_type] = handler
|
|
||||||
|
|
||||||
def get(self, node_type: str) -> NodeHandler:
|
|
||||||
try:
|
|
||||||
return self._handlers[node_type]
|
|
||||||
except KeyError as exc:
|
|
||||||
raise KeyError(f"No graph node handler registered for {node_type}") from exc
|
|
||||||
|
|
||||||
def has(self, node_type: str) -> bool:
|
|
||||||
return node_type in self._handlers
|
|
||||||
|
|
@ -9,7 +9,7 @@ class GraphRuntime(ABC):
|
||||||
"""Execution runtime boundary. LangGraph must stay behind this interface."""
|
"""Execution runtime boundary. LangGraph must stay behind this interface."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def start(self, project_id: UUID | None = None, **kwargs: Any) -> str: ...
|
async def start(self, project_id: UUID) -> str: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def pause(self, run_id: str) -> None: ...
|
async def pause(self, run_id: str) -> None: ...
|
||||||
|
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class GraphNodeSpec:
|
|
||||||
node_id: str
|
|
||||||
node_type: str
|
|
||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
return {"id": self.node_id, "type": self.node_type, "metadata": self.metadata}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class GraphEdgeSpec:
|
|
||||||
source: str
|
|
||||||
target: str
|
|
||||||
condition: str = ""
|
|
||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
return {"source": self.source, "target": self.target, "condition": self.condition, "metadata": self.metadata}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ExecutionGraphSpec:
|
|
||||||
name: str
|
|
||||||
version: int
|
|
||||||
graph_type: str
|
|
||||||
entry: str
|
|
||||||
nodes: dict[str, GraphNodeSpec]
|
|
||||||
edges: list[GraphEdgeSpec]
|
|
||||||
terminal_nodes: list[str]
|
|
||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"name": self.name,
|
|
||||||
"version": self.version,
|
|
||||||
"graph_type": self.graph_type,
|
|
||||||
"entry": self.entry,
|
|
||||||
"nodes": {node_id: node.to_dict() for node_id, node in self.nodes.items()},
|
|
||||||
"edges": [edge.to_dict() for edge in self.edges],
|
|
||||||
"terminal_nodes": self.terminal_nodes,
|
|
||||||
"metadata": self.metadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, payload: dict[str, Any]) -> "ExecutionGraphSpec":
|
|
||||||
nodes = {
|
|
||||||
node_id: GraphNodeSpec(node_id=str(raw["id"]), node_type=str(raw["type"]), metadata=dict(raw.get("metadata", {})))
|
|
||||||
for node_id, raw in dict(payload["nodes"]).items()
|
|
||||||
}
|
|
||||||
edges = [
|
|
||||||
GraphEdgeSpec(
|
|
||||||
source=str(raw["source"]),
|
|
||||||
target=str(raw["target"]),
|
|
||||||
condition=str(raw.get("condition", "")),
|
|
||||||
metadata=dict(raw.get("metadata", {})),
|
|
||||||
)
|
|
||||||
for raw in list(payload["edges"])
|
|
||||||
]
|
|
||||||
return cls(
|
|
||||||
name=str(payload["name"]),
|
|
||||||
version=int(payload["version"]),
|
|
||||||
graph_type=str(payload["graph_type"]),
|
|
||||||
entry=str(payload["entry"]),
|
|
||||||
nodes=nodes,
|
|
||||||
edges=edges,
|
|
||||||
terminal_nodes=[str(item) for item in payload.get("terminal_nodes", [])],
|
|
||||||
metadata=dict(payload.get("metadata", {})),
|
|
||||||
)
|
|
||||||
|
|
||||||
def validate(self) -> None:
|
|
||||||
if self.entry not in self.nodes:
|
|
||||||
raise ValueError(f"Graph entry node does not exist: {self.entry}")
|
|
||||||
for terminal in self.terminal_nodes:
|
|
||||||
if terminal not in self.nodes:
|
|
||||||
raise ValueError(f"Terminal node does not exist: {terminal}")
|
|
||||||
for edge in self.edges:
|
|
||||||
if edge.source not in self.nodes:
|
|
||||||
raise ValueError(f"Edge source does not exist: {edge.source}")
|
|
||||||
if edge.target not in self.nodes:
|
|
||||||
raise ValueError(f"Edge target does not exist: {edge.target}")
|
|
||||||
|
|
||||||
|
|
||||||
def outgoing_edges(spec: ExecutionGraphSpec, node_id: str) -> list[GraphEdgeSpec]:
|
|
||||||
return [edge for edge in spec.edges if edge.source == node_id]
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from graph.spec import ExecutionGraphSpec, GraphEdgeSpec, GraphNodeSpec
|
|
||||||
|
|
||||||
|
|
||||||
TASK_EXECUTION_GRAPH_NAME = "task_execution"
|
|
||||||
TASK_EXECUTION_GRAPH_VERSION = 1
|
|
||||||
|
|
||||||
|
|
||||||
def task_execution_graph_v1() -> ExecutionGraphSpec:
|
|
||||||
nodes = {
|
|
||||||
"claim_task": GraphNodeSpec("claim_task", "claim_task", {"idempotent": False, "replay_safe": False}),
|
|
||||||
"prepare_worktree": GraphNodeSpec("prepare_worktree", "prepare_worktree", {"idempotent": True, "replay_safe": True}),
|
|
||||||
"build_context": GraphNodeSpec("build_context", "build_context", {"idempotent": True, "replay_safe": True}),
|
|
||||||
"coder": GraphNodeSpec("coder", "coder", {"idempotent": False, "replay_safe": "checkpointed", "contains_internal_tool_loop": True}),
|
|
||||||
"run_tests": GraphNodeSpec("run_tests", "run_tests", {"idempotent": True, "replay_safe": "checkpointed"}),
|
|
||||||
"review": GraphNodeSpec("review", "review", {"idempotent": True, "replay_safe": "checkpointed"}),
|
|
||||||
"judge": GraphNodeSpec("judge", "judge", {"idempotent": True, "replay_safe": "checkpointed"}),
|
|
||||||
"commit": GraphNodeSpec("commit", "commit", {"idempotent": False, "replay_safe": "guarded", "destructive": True}),
|
|
||||||
"retry_or_fail": GraphNodeSpec("retry_or_fail", "retry_or_fail", {"idempotent": False, "replay_safe": "checkpointed"}),
|
|
||||||
"cleanup": GraphNodeSpec("cleanup", "cleanup", {"idempotent": True, "replay_safe": True}),
|
|
||||||
"complete": GraphNodeSpec("complete", "complete", {"terminal": True}),
|
|
||||||
"fail": GraphNodeSpec("fail", "fail", {"terminal": True}),
|
|
||||||
}
|
|
||||||
edges = [
|
|
||||||
GraphEdgeSpec("claim_task", "prepare_worktree", "success"),
|
|
||||||
GraphEdgeSpec("prepare_worktree", "build_context", "success"),
|
|
||||||
GraphEdgeSpec("build_context", "coder", "success"),
|
|
||||||
GraphEdgeSpec("coder", "run_tests", "success"),
|
|
||||||
GraphEdgeSpec("coder", "retry_or_fail", "failure"),
|
|
||||||
GraphEdgeSpec("run_tests", "review", "complete"),
|
|
||||||
GraphEdgeSpec("review", "judge", "PASS"),
|
|
||||||
GraphEdgeSpec("review", "retry_or_fail", "REWORK_REQUIRED"),
|
|
||||||
GraphEdgeSpec("review", "retry_or_fail", "REJECTED"),
|
|
||||||
GraphEdgeSpec("judge", "commit", "PASS"),
|
|
||||||
GraphEdgeSpec("judge", "retry_or_fail", "FAIL"),
|
|
||||||
GraphEdgeSpec("commit", "cleanup", "success"),
|
|
||||||
GraphEdgeSpec("cleanup", "complete", "success"),
|
|
||||||
GraphEdgeSpec("retry_or_fail", "build_context", "retry_available"),
|
|
||||||
GraphEdgeSpec("retry_or_fail", "cleanup", "retry_exhausted"),
|
|
||||||
GraphEdgeSpec("cleanup", "fail", "failed"),
|
|
||||||
]
|
|
||||||
spec = ExecutionGraphSpec(
|
|
||||||
name=TASK_EXECUTION_GRAPH_NAME,
|
|
||||||
version=TASK_EXECUTION_GRAPH_VERSION,
|
|
||||||
graph_type="TASK_EXECUTION",
|
|
||||||
entry="claim_task",
|
|
||||||
nodes=nodes,
|
|
||||||
edges=edges,
|
|
||||||
terminal_nodes=["complete", "fail"],
|
|
||||||
metadata={"description": "Task execution graph v1 mirrors AutonomousTaskLoop semantics."},
|
|
||||||
)
|
|
||||||
spec.validate()
|
|
||||||
return spec
|
|
||||||
|
|
@ -1,356 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from agents.coder import Coder
|
|
||||||
from agents.judge import Judge
|
|
||||||
from agents.reviewer import Reviewer
|
|
||||||
from control_plane.agents.models import AgentRole, AgentVersion
|
|
||||||
from control_plane.events.bus import EventBus
|
|
||||||
from control_plane.events.models import EventType
|
|
||||||
from control_plane.projects.models import CommitRecord, Task, TaskAttempt, TaskStatus, Worktree
|
|
||||||
from control_plane.verification.models import TestRun, VerificationResult
|
|
||||||
from graph.native_runtime import GraphExecutionContext
|
|
||||||
from graph.registry import NodeHandlerRegistry, NodeResult
|
|
||||||
from knowledge.context_builder import WorkerContextBuilder
|
|
||||||
from model_router.router import ModelRouter
|
|
||||||
from tools.capabilities import Capability
|
|
||||||
from tools.runtime import WorktreeTools
|
|
||||||
from tools.test_runner import DeterministicTestRunner
|
|
||||||
from workspace.worktrees import WorktreeManager
|
|
||||||
|
|
||||||
|
|
||||||
class TaskExecutionServices:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
router: ModelRouter,
|
|
||||||
*,
|
|
||||||
bus: EventBus | None = None,
|
|
||||||
test_command: list[str] | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.router = router
|
|
||||||
self.bus = bus or EventBus()
|
|
||||||
self.context_builder = WorkerContextBuilder()
|
|
||||||
self.worktrees = WorktreeManager()
|
|
||||||
self.coder = Coder(router)
|
|
||||||
self.reviewer = Reviewer()
|
|
||||||
self.judge = Judge()
|
|
||||||
self.tests = DeterministicTestRunner()
|
|
||||||
self.test_command = test_command or ["python", "-m", "pytest"]
|
|
||||||
|
|
||||||
def champion(self, role: AgentRole) -> AgentVersion:
|
|
||||||
return AgentVersion.objects.select_related("agent").get(agent__role=role, promotion_status="CHAMPION")
|
|
||||||
|
|
||||||
def tools(self, worktree: Worktree) -> WorktreeTools:
|
|
||||||
return WorktreeTools(
|
|
||||||
Path(worktree.worktree_path),
|
|
||||||
{
|
|
||||||
Capability.READ_REPOSITORY,
|
|
||||||
Capability.INVESTIGATE_WORKTREE,
|
|
||||||
Capability.WRITE_WORKTREE,
|
|
||||||
Capability.RUN_TESTS,
|
|
||||||
Capability.COMMIT_CHANGES,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def worktree(self, task: Task) -> Worktree:
|
|
||||||
try:
|
|
||||||
return task.worktree
|
|
||||||
except Worktree.DoesNotExist:
|
|
||||||
if not task.project.repository_path:
|
|
||||||
raise RuntimeError("Task project has no repository_path")
|
|
||||||
return self.worktrees.create_for_task(task, Path(task.project.repository_path))
|
|
||||||
|
|
||||||
|
|
||||||
class TaskNode:
|
|
||||||
idempotent = True
|
|
||||||
replay_safe = "checkpointed"
|
|
||||||
destructive = False
|
|
||||||
|
|
||||||
def __init__(self, services: TaskExecutionServices, node_type: str) -> None:
|
|
||||||
self.services = services
|
|
||||||
self.node_type = node_type
|
|
||||||
|
|
||||||
def task(self, context: GraphExecutionContext) -> Task:
|
|
||||||
if context.graph_run.task_id is None:
|
|
||||||
raise RuntimeError("Task execution graph run requires task")
|
|
||||||
return Task.objects.select_related("project", "milestone", "feature").get(id=context.graph_run.task_id)
|
|
||||||
|
|
||||||
def metadata(self, context: GraphExecutionContext) -> dict[str, object]:
|
|
||||||
return dict(context.graph_run.metadata)
|
|
||||||
|
|
||||||
def save_metadata(self, context: GraphExecutionContext, metadata: dict[str, object]) -> None:
|
|
||||||
context.graph_run.metadata = metadata
|
|
||||||
context.graph_run.save(update_fields=["metadata", "updated_at"])
|
|
||||||
|
|
||||||
|
|
||||||
class ClaimTaskNode(TaskNode):
|
|
||||||
def __init__(self, services: TaskExecutionServices) -> None:
|
|
||||||
super().__init__(services, "claim_task")
|
|
||||||
self.idempotent = True
|
|
||||||
|
|
||||||
def run(self, context: GraphExecutionContext) -> NodeResult:
|
|
||||||
task = self.task(context)
|
|
||||||
return NodeResult("COMPLETE", "success", {"task_id": str(task.id), "task_status": task.status})
|
|
||||||
|
|
||||||
|
|
||||||
class PrepareWorktreeNode(TaskNode):
|
|
||||||
def __init__(self, services: TaskExecutionServices) -> None:
|
|
||||||
super().__init__(services, "prepare_worktree")
|
|
||||||
|
|
||||||
def run(self, context: GraphExecutionContext) -> NodeResult:
|
|
||||||
task = self.task(context)
|
|
||||||
worktree = self.services.worktree(task)
|
|
||||||
metadata = self.metadata(context)
|
|
||||||
metadata["worktree_id"] = str(worktree.id)
|
|
||||||
metadata["worktree_path"] = worktree.worktree_path
|
|
||||||
self.save_metadata(context, metadata)
|
|
||||||
return NodeResult("COMPLETE", "success", {"worktree_id": str(worktree.id), "worktree_path": worktree.worktree_path})
|
|
||||||
|
|
||||||
|
|
||||||
class BuildContextNode(TaskNode):
|
|
||||||
def __init__(self, services: TaskExecutionServices) -> None:
|
|
||||||
super().__init__(services, "build_context")
|
|
||||||
|
|
||||||
def run(self, context: GraphExecutionContext) -> NodeResult:
|
|
||||||
task = self.task(context)
|
|
||||||
worktree = self.services.worktree(task)
|
|
||||||
metadata = self.metadata(context)
|
|
||||||
attempt_id = metadata.get("current_attempt_id")
|
|
||||||
if attempt_id:
|
|
||||||
attempt = TaskAttempt.objects.get(id=attempt_id)
|
|
||||||
return NodeResult("COMPLETE", "success", {"attempt_id": str(attempt.id), "attempt_number": attempt.attempt_number})
|
|
||||||
coder_version = self.services.champion(AgentRole.CODER)
|
|
||||||
attempt = TaskAttempt.objects.create(task=task, attempt_number=task.retry_count + 1, coder=coder_version, status="RUNNING")
|
|
||||||
task_context = self.services.context_builder.build_for_task(task, Path(worktree.worktree_path))
|
|
||||||
task_context["previous_attempts"] = list(
|
|
||||||
task.attempts.exclude(id=attempt.id).order_by("attempt_number").values("attempt_number", "status", "coder_result", "review_findings", "judge_findings")
|
|
||||||
)
|
|
||||||
attempt.context_snapshot = task_context
|
|
||||||
attempt.save(update_fields=["context_snapshot", "updated_at"])
|
|
||||||
metadata["current_attempt_id"] = str(attempt.id)
|
|
||||||
self.save_metadata(context, metadata)
|
|
||||||
context.graph_run.task_attempt = attempt
|
|
||||||
context.graph_run.save(update_fields=["task_attempt", "updated_at"])
|
|
||||||
return NodeResult("COMPLETE", "success", {"attempt_id": str(attempt.id), "attempt_number": attempt.attempt_number})
|
|
||||||
|
|
||||||
|
|
||||||
class CoderNode(TaskNode):
|
|
||||||
def __init__(self, services: TaskExecutionServices) -> None:
|
|
||||||
super().__init__(services, "coder")
|
|
||||||
self.idempotent = False
|
|
||||||
|
|
||||||
def run(self, context: GraphExecutionContext) -> NodeResult:
|
|
||||||
task = self.task(context)
|
|
||||||
worktree = self.services.worktree(task)
|
|
||||||
metadata = self.metadata(context)
|
|
||||||
attempt = TaskAttempt.objects.get(id=metadata["current_attempt_id"])
|
|
||||||
coder_version = attempt.coder
|
|
||||||
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]
|
|
||||||
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 {})
|
|
||||||
|
|
||||||
|
|
||||||
class RunTestsNode(TaskNode):
|
|
||||||
def __init__(self, services: TaskExecutionServices) -> None:
|
|
||||||
super().__init__(services, "run_tests")
|
|
||||||
|
|
||||||
def run(self, context: GraphExecutionContext) -> NodeResult:
|
|
||||||
task = self.task(context)
|
|
||||||
worktree = self.services.worktree(task)
|
|
||||||
metadata = self.metadata(context)
|
|
||||||
attempt = TaskAttempt.objects.get(id=metadata["current_attempt_id"])
|
|
||||||
test_run = self.services.tests.run(task.project, task, Path(worktree.worktree_path), self.services.test_command)
|
|
||||||
metadata["test_run_id"] = str(test_run.id)
|
|
||||||
self.save_metadata(context, metadata)
|
|
||||||
if test_run.status != "PASS":
|
|
||||||
self._attach_test_failure_evidence(attempt, test_run)
|
|
||||||
self.services.bus.publish(EventType.TEST_FAILED, project=task.project, task=task, payload={"test_run_id": str(test_run.id)})
|
|
||||||
return NodeResult("COMPLETE", "complete", {"test_run_id": str(test_run.id), "test_status": test_run.status}, {"test_status": test_run.status})
|
|
||||||
|
|
||||||
def _attach_test_failure_evidence(self, attempt: TaskAttempt, test_run: TestRun) -> None:
|
|
||||||
content = test_run.output_artifact.content if test_run.output_artifact else {}
|
|
||||||
stdout = str(content.get("stdout", "")) if isinstance(content, dict) else ""
|
|
||||||
stderr = str(content.get("stderr", "")) if isinstance(content, dict) else ""
|
|
||||||
coder_result = dict(attempt.coder_result or {})
|
|
||||||
attempt_metadata = dict(coder_result.get("metadata", {})) if isinstance(coder_result.get("metadata", {}), dict) else {}
|
|
||||||
attempt_metadata["test_failure_evidence"] = {"test_run_id": str(test_run.id), "status": test_run.status, "stdout_excerpt": stdout[-12000:], "stderr_excerpt": stderr[-4000:]}
|
|
||||||
coder_result["metadata"] = attempt_metadata
|
|
||||||
attempt.coder_result = coder_result
|
|
||||||
attempt.save(update_fields=["coder_result", "updated_at"])
|
|
||||||
|
|
||||||
|
|
||||||
class ReviewNode(TaskNode):
|
|
||||||
def __init__(self, services: TaskExecutionServices) -> None:
|
|
||||||
super().__init__(services, "review")
|
|
||||||
|
|
||||||
def run(self, context: GraphExecutionContext) -> NodeResult:
|
|
||||||
task = self.task(context)
|
|
||||||
metadata = self.metadata(context)
|
|
||||||
attempt = TaskAttempt.objects.get(id=metadata["current_attempt_id"])
|
|
||||||
reviewer_version = self.services.champion(AgentRole.REVIEWER)
|
|
||||||
worktree = self.services.worktree(task)
|
|
||||||
tools = self.services.tools(worktree)
|
|
||||||
tools.git(["add", "-N", "."])
|
|
||||||
diff = tools.diff()
|
|
||||||
test_run = TestRun.objects.get(id=metadata["test_run_id"])
|
|
||||||
review = self.services.reviewer.review(task, reviewer_version, diff, test_run.status)
|
|
||||||
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
|
|
||||||
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})
|
|
||||||
return NodeResult("COMPLETE", review.status, {"review_id": str(review.id), "review_status": review.status, "findings": review.findings}, {"review_status": review.status})
|
|
||||||
|
|
||||||
|
|
||||||
class JudgeNode(TaskNode):
|
|
||||||
def __init__(self, services: TaskExecutionServices) -> None:
|
|
||||||
super().__init__(services, "judge")
|
|
||||||
|
|
||||||
def run(self, context: GraphExecutionContext) -> NodeResult:
|
|
||||||
task = self.task(context)
|
|
||||||
metadata = self.metadata(context)
|
|
||||||
attempt = TaskAttempt.objects.get(id=metadata["current_attempt_id"])
|
|
||||||
judge_version = self.services.champion(AgentRole.PROJECT_JUDGE)
|
|
||||||
test_run = TestRun.objects.get(id=metadata["test_run_id"])
|
|
||||||
worktree = self.services.worktree(task)
|
|
||||||
tools = self.services.tools(worktree)
|
|
||||||
tools.git(["add", "-N", "."])
|
|
||||||
diff = tools.diff()
|
|
||||||
verification = self.services.judge.judge(task.project, task, judge_version, diff, test_run.status)
|
|
||||||
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
|
|
||||||
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})
|
|
||||||
|
|
||||||
|
|
||||||
class RetryOrFailNode(TaskNode):
|
|
||||||
def __init__(self, services: TaskExecutionServices) -> None:
|
|
||||||
super().__init__(services, "retry_or_fail")
|
|
||||||
self.idempotent = False
|
|
||||||
|
|
||||||
def run(self, context: GraphExecutionContext) -> NodeResult:
|
|
||||||
task = self.task(context)
|
|
||||||
metadata = self.metadata(context)
|
|
||||||
attempt = TaskAttempt.objects.get(id=metadata["current_attempt_id"])
|
|
||||||
effective_max_retries = min(task.max_retries, 2)
|
|
||||||
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", [])
|
|
||||||
classification = self._classify_failure(reason, findings)
|
|
||||||
metadata.pop("current_attempt_id", None)
|
|
||||||
self.save_metadata(context, metadata)
|
|
||||||
if task.retry_count <= effective_max_retries:
|
|
||||||
task.status = TaskStatus.RUNNING
|
|
||||||
task.save(update_fields=["retry_count", "status", "updated_at"])
|
|
||||||
self.services.bus.publish(EventType.TASK_FAILED, project=task.project, task=task, payload={"reason": reason, "will_retry": True, "classification": classification, "findings": findings})
|
|
||||||
return NodeResult("COMPLETE", "retry_available", {"retry_count": task.retry_count, "classification": classification})
|
|
||||||
task.status = TaskStatus.FAILED
|
|
||||||
task.save(update_fields=["retry_count", "status", "updated_at"])
|
|
||||||
self.services.bus.publish("TASK_RETRY_EXHAUSTED", project=task.project, task=task, payload={"reason": reason, "classification": classification, "findings": findings})
|
|
||||||
self.services.bus.publish(EventType.TASK_FAILED, project=task.project, task=task, payload={"reason": reason, "will_retry": False, "classification": classification, "findings": findings})
|
|
||||||
return NodeResult("COMPLETE", "retry_exhausted", {"retry_count": task.retry_count, "classification": classification})
|
|
||||||
|
|
||||||
def _classify_failure(self, reason: str, findings: object) -> str:
|
|
||||||
text = f"{reason} {findings}".lower()
|
|
||||||
if "unsupported operation" in text or "missing capability" in text:
|
|
||||||
return "missing_capability"
|
|
||||||
if "context" in text or "migration" in text:
|
|
||||||
return "context_problem"
|
|
||||||
if "timeout" in text or "provider" in text:
|
|
||||||
return "environment_problem"
|
|
||||||
if "malformed json" in text or "model" in text:
|
|
||||||
return "model_problem"
|
|
||||||
if "ambiguous" in text:
|
|
||||||
return "intent_ambiguity"
|
|
||||||
if reason == "review_failed" or reason == "judge_failed":
|
|
||||||
return "replan"
|
|
||||||
return "split_task"
|
|
||||||
|
|
||||||
|
|
||||||
class CommitNode(TaskNode):
|
|
||||||
destructive = True
|
|
||||||
|
|
||||||
def __init__(self, services: TaskExecutionServices) -> None:
|
|
||||||
super().__init__(services, "commit")
|
|
||||||
self.idempotent = False
|
|
||||||
|
|
||||||
def run(self, context: GraphExecutionContext) -> NodeResult:
|
|
||||||
task = self.task(context)
|
|
||||||
existing = CommitRecord.objects.filter(task=task).first()
|
|
||||||
if existing is not None:
|
|
||||||
return NodeResult("COMPLETE", "success", {"commit_id": str(existing.id), "sha": existing.sha, "deduplicated": True})
|
|
||||||
metadata = self.metadata(context)
|
|
||||||
attempt = TaskAttempt.objects.get(id=metadata["current_attempt_id"])
|
|
||||||
worktree = self.services.worktree(task)
|
|
||||||
test_run = TestRun.objects.get(id=metadata["test_run_id"])
|
|
||||||
sha = self.services.tools(worktree).commit_all(f"Artifex task: {task.goal[:80]}")
|
|
||||||
commit = CommitRecord.objects.create(
|
|
||||||
project=task.project,
|
|
||||||
task=task,
|
|
||||||
worktree=worktree,
|
|
||||||
coder=attempt.coder,
|
|
||||||
reviewer=self.services.champion(AgentRole.REVIEWER),
|
|
||||||
judge=self.services.champion(AgentRole.PROJECT_JUDGE),
|
|
||||||
test_run=test_run,
|
|
||||||
review_id=metadata.get("review_id"),
|
|
||||||
verification_id=metadata.get("verification_id"),
|
|
||||||
graph_run=context.graph_run,
|
|
||||||
sha=sha,
|
|
||||||
branch_name=worktree.branch_name,
|
|
||||||
message=f"Artifex task: {task.goal[:80]}",
|
|
||||||
)
|
|
||||||
attempt.status = "COMPLETE"
|
|
||||||
attempt.save(update_fields=["status", "updated_at"])
|
|
||||||
task.status = TaskStatus.COMPLETE
|
|
||||||
task.save(update_fields=["status", "updated_at"])
|
|
||||||
self.services.bus.publish(EventType.COMMIT_CREATED, project=task.project, task=task, payload={"commit_id": str(commit.id), "sha": sha})
|
|
||||||
self.services.bus.publish(EventType.TASK_COMPLETED, project=task.project, task=task, payload={"task_id": str(task.id)})
|
|
||||||
metadata["commit_id"] = str(commit.id)
|
|
||||||
metadata["commit_sha"] = sha
|
|
||||||
self.save_metadata(context, metadata)
|
|
||||||
return NodeResult("COMPLETE", "success", {"commit_id": str(commit.id), "sha": sha})
|
|
||||||
|
|
||||||
|
|
||||||
class CleanupNode(TaskNode):
|
|
||||||
def __init__(self, services: TaskExecutionServices) -> None:
|
|
||||||
super().__init__(services, "cleanup")
|
|
||||||
|
|
||||||
def run(self, context: GraphExecutionContext) -> NodeResult:
|
|
||||||
task = self.task(context)
|
|
||||||
if task.status == TaskStatus.COMPLETE:
|
|
||||||
self.services.worktrees.validate_clean_worktree(task.worktree)
|
|
||||||
self.services.worktrees.cleanup(task.worktree)
|
|
||||||
return NodeResult("COMPLETE", "success", {"cleaned": True})
|
|
||||||
return NodeResult("COMPLETE", "failed", {"cleaned": False})
|
|
||||||
|
|
||||||
|
|
||||||
def task_execution_registry(services: TaskExecutionServices) -> NodeHandlerRegistry:
|
|
||||||
registry = NodeHandlerRegistry()
|
|
||||||
for handler in [
|
|
||||||
ClaimTaskNode(services),
|
|
||||||
PrepareWorktreeNode(services),
|
|
||||||
BuildContextNode(services),
|
|
||||||
CoderNode(services),
|
|
||||||
RunTestsNode(services),
|
|
||||||
ReviewNode(services),
|
|
||||||
JudgeNode(services),
|
|
||||||
CommitNode(services),
|
|
||||||
RetryOrFailNode(services),
|
|
||||||
CleanupNode(services),
|
|
||||||
]:
|
|
||||||
registry.register(handler)
|
|
||||||
return registry
|
|
||||||
|
|
@ -6,7 +6,6 @@ requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"django>=5.1,<6.0",
|
"django>=5.1,<6.0",
|
||||||
"psycopg[binary]>=3.2,<4.0",
|
"psycopg[binary]>=3.2,<4.0",
|
||||||
"langgraph>=0.2,<0.3",
|
|
||||||
"structlog>=24.4,<25.0",
|
"structlog>=24.4,<25.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,7 @@ from control_plane.events.bus import EventBus
|
||||||
from control_plane.events.models import EventType
|
from control_plane.events.models import EventType
|
||||||
from control_plane.projects.models import CommitRecord, Task, TaskAttempt, TaskStatus, Worktree
|
from control_plane.projects.models import CommitRecord, Task, TaskAttempt, TaskStatus, Worktree
|
||||||
from control_plane.verification.models import VerificationResult
|
from control_plane.verification.models import VerificationResult
|
||||||
from graph.bootstrap import champion_task_execution_graph_v1
|
|
||||||
from graph.models import GraphRun
|
|
||||||
from graph.native_runtime import NativeGraphRuntime
|
|
||||||
from graph.scheduler import TaskScheduler
|
from graph.scheduler import TaskScheduler
|
||||||
from graph.task_nodes import TaskExecutionServices, task_execution_registry
|
|
||||||
from knowledge.context_builder import WorkerContextBuilder
|
from knowledge.context_builder import WorkerContextBuilder
|
||||||
from model_router.router import ModelRouter
|
from model_router.router import ModelRouter
|
||||||
from tools.capabilities import Capability
|
from tools.capabilities import Capability
|
||||||
|
|
@ -49,19 +45,6 @@ class AutonomousTaskLoop:
|
||||||
return task
|
return task
|
||||||
|
|
||||||
def _execute_task(self, task: Task, test_command: list[str]) -> None:
|
def _execute_task(self, task: Task, test_command: list[str]) -> None:
|
||||||
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"],
|
|
||||||
)
|
|
||||||
services = TaskExecutionServices(self.router, bus=self.bus, test_command=test_command)
|
|
||||||
NativeGraphRuntime(task_execution_registry(services), bus=self.bus).run_until_terminal_or_paused(graph_run)
|
|
||||||
return
|
|
||||||
|
|
||||||
coder_version = self._champion(AgentRole.CODER)
|
coder_version = self._champion(AgentRole.CODER)
|
||||||
reviewer_version = self._champion(AgentRole.REVIEWER)
|
reviewer_version = self._champion(AgentRole.REVIEWER)
|
||||||
judge_version = self._champion(AgentRole.PROJECT_JUDGE)
|
judge_version = self._champion(AgentRole.PROJECT_JUDGE)
|
||||||
|
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from control_plane.agents.models import ProgenySignal
|
|
||||||
from graph.models import ExecutionGraphDefinition, ExecutionGraphVersion, ExecutionGraphVersionStatus, GraphRun, GraphRunStatus
|
|
||||||
from graph.registry import NodeHandlerRegistry, NodeResult
|
|
||||||
from graph.spec import ExecutionGraphSpec, outgoing_edges
|
|
||||||
from graph.task_execution import task_execution_graph_v1
|
|
||||||
|
|
||||||
|
|
||||||
class FixtureNode:
|
|
||||||
node_type = "fixture"
|
|
||||||
idempotent = True
|
|
||||||
replay_safe = True
|
|
||||||
destructive = False
|
|
||||||
|
|
||||||
def run(self, context: object) -> NodeResult:
|
|
||||||
return NodeResult("COMPLETE", "success", {"ok": True})
|
|
||||||
|
|
||||||
|
|
||||||
def test_task_execution_graph_v1_is_serializable_and_valid() -> None:
|
|
||||||
spec = task_execution_graph_v1()
|
|
||||||
|
|
||||||
payload = spec.to_dict()
|
|
||||||
restored = ExecutionGraphSpec.from_dict(payload)
|
|
||||||
restored.validate()
|
|
||||||
|
|
||||||
assert restored.name == "task_execution"
|
|
||||||
assert restored.version == 1
|
|
||||||
assert restored.entry == "claim_task"
|
|
||||||
assert restored.nodes["coder"].metadata["contains_internal_tool_loop"] is True
|
|
||||||
assert {edge.condition for edge in outgoing_edges(restored, "review")} == {"PASS", "REWORK_REQUIRED", "REJECTED"}
|
|
||||||
assert {edge.condition for edge in outgoing_edges(restored, "retry_or_fail")} == {"retry_available", "retry_exhausted"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_node_registry_resolves_handlers_without_arbitrary_execution() -> None:
|
|
||||||
registry = NodeHandlerRegistry()
|
|
||||||
registry.register(FixtureNode())
|
|
||||||
|
|
||||||
assert registry.get("fixture").run(object()).output_metadata == {"ok": True}
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
registry.get("missing")
|
|
||||||
|
|
||||||
|
|
||||||
def test_graph_models_persist_versioned_run_state() -> None:
|
|
||||||
spec = task_execution_graph_v1()
|
|
||||||
definition = ExecutionGraphDefinition.objects.create(
|
|
||||||
name=spec.name,
|
|
||||||
graph_type=spec.graph_type,
|
|
||||||
description="Task execution graph",
|
|
||||||
)
|
|
||||||
version = ExecutionGraphVersion.objects.create(
|
|
||||||
graph=definition,
|
|
||||||
version=spec.version,
|
|
||||||
status=ExecutionGraphVersionStatus.CHAMPION,
|
|
||||||
graph_spec=spec.to_dict(),
|
|
||||||
metadata={"immutable_after_use": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
run = GraphRun.objects.create(
|
|
||||||
execution_graph_version=version,
|
|
||||||
status=GraphRunStatus.PENDING,
|
|
||||||
current_node=spec.entry,
|
|
||||||
metadata={"node_count": len(spec.nodes)},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert run.execution_graph_version.graph.name == "task_execution"
|
|
||||||
assert run.execution_graph_version.status == ExecutionGraphVersionStatus.CHAMPION
|
|
||||||
assert run.metadata["node_count"] == 12
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
version = ExecutionGraphVersion.objects.create(graph=definition, version=1, status=ExecutionGraphVersionStatus.CHAMPION, graph_spec=spec.to_dict())
|
|
||||||
run = GraphRun.objects.create(execution_graph_version=version, current_node=spec.entry)
|
|
||||||
node_run = run.node_runs.create(node_id="coder", node_type="coder", visit_index=1)
|
|
||||||
|
|
||||||
signal = ProgenySignal.objects.create(
|
|
||||||
source="runtime",
|
|
||||||
severity="high",
|
|
||||||
failure_category="MODEL_OUTPUT_INVALID",
|
|
||||||
summary="Malformed model JSON",
|
|
||||||
graph_run=run,
|
|
||||||
graph_node_run=node_run,
|
|
||||||
execution_graph_version=version,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert signal.graph_run == run
|
|
||||||
assert signal.graph_node_run == node_run
|
|
||||||
assert signal.execution_graph_version == version
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from graph.inspection import graph_run_inspection
|
|
||||||
from graph.models import ExecutionGraphDefinition, ExecutionGraphVersion, ExecutionGraphVersionStatus, GraphApproval, GraphApprovalStatus, GraphRun, GraphRunStatus
|
|
||||||
from graph.native_runtime import GraphExecutionContext, NativeGraphRuntime
|
|
||||||
from graph.registry import NodeHandlerRegistry, NodeResult
|
|
||||||
from graph.spec import ExecutionGraphSpec, GraphEdgeSpec, GraphNodeSpec
|
|
||||||
|
|
||||||
|
|
||||||
def persist_spec(spec: ExecutionGraphSpec) -> GraphRun:
|
|
||||||
definition = ExecutionGraphDefinition.objects.create(name=spec.name, graph_type=spec.graph_type)
|
|
||||||
version = ExecutionGraphVersion.objects.create(graph=definition, version=spec.version, status=ExecutionGraphVersionStatus.CHAMPION, graph_spec=spec.to_dict())
|
|
||||||
return GraphRun.objects.create(execution_graph_version=version, current_node=spec.entry)
|
|
||||||
|
|
||||||
|
|
||||||
class ApprovalGate:
|
|
||||||
node_type = "approval_gate"
|
|
||||||
idempotent = True
|
|
||||||
replay_safe = "checkpointed"
|
|
||||||
destructive = False
|
|
||||||
|
|
||||||
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")
|
|
||||||
GraphApproval.objects.get_or_create(graph_run=context.graph_run, node_run=node_run, reason="AWAITING_APPROVAL")
|
|
||||||
return NodeResult("PAUSED", "awaiting", pause_reason="AWAITING_APPROVAL")
|
|
||||||
|
|
||||||
|
|
||||||
class DoneNode:
|
|
||||||
node_type = "done_node"
|
|
||||||
idempotent = True
|
|
||||||
replay_safe = True
|
|
||||||
destructive = False
|
|
||||||
|
|
||||||
def run(self, context: GraphExecutionContext) -> NodeResult:
|
|
||||||
return NodeResult("COMPLETE", "success")
|
|
||||||
|
|
||||||
|
|
||||||
def test_human_approval_pause_signal_resume() -> None:
|
|
||||||
spec = ExecutionGraphSpec(
|
|
||||||
name="approval_fixture",
|
|
||||||
version=1,
|
|
||||||
graph_type="FIXTURE",
|
|
||||||
entry="approval",
|
|
||||||
nodes={"approval": GraphNodeSpec("approval", "approval_gate"), "done": GraphNodeSpec("done", "done_node")},
|
|
||||||
edges=[GraphEdgeSpec("approval", "done", "approved")],
|
|
||||||
terminal_nodes=["done"],
|
|
||||||
)
|
|
||||||
graph_run = persist_spec(spec)
|
|
||||||
registry = NodeHandlerRegistry()
|
|
||||||
registry.register(ApprovalGate())
|
|
||||||
registry.register(DoneNode())
|
|
||||||
runtime = NativeGraphRuntime(registry)
|
|
||||||
|
|
||||||
runtime.run_until_terminal_or_paused(graph_run)
|
|
||||||
graph_run.refresh_from_db()
|
|
||||||
assert graph_run.status == GraphRunStatus.PAUSED
|
|
||||||
assert graph_run.approvals.get().status == GraphApprovalStatus.PENDING
|
|
||||||
|
|
||||||
runtime.signal_now(str(graph_run.id), {"action": "approve", "actor": "tester"})
|
|
||||||
runtime.run_until_terminal_or_paused(graph_run)
|
|
||||||
graph_run.refresh_from_db()
|
|
||||||
assert graph_run.status == GraphRunStatus.COMPLETE
|
|
||||||
assert graph_run.approvals.get().status == GraphApprovalStatus.APPROVED
|
|
||||||
|
|
||||||
|
|
||||||
def test_subgraph_can_be_represented_in_runtime_neutral_spec() -> None:
|
|
||||||
spec = ExecutionGraphSpec(
|
|
||||||
name="subgraph_fixture",
|
|
||||||
version=1,
|
|
||||||
graph_type="FIXTURE",
|
|
||||||
entry="parent",
|
|
||||||
nodes={
|
|
||||||
"parent": GraphNodeSpec("parent", "subgraph", {"subgraph": {"name": "child_graph", "version": 1}}),
|
|
||||||
"done": GraphNodeSpec("done", "done_node"),
|
|
||||||
},
|
|
||||||
edges=[GraphEdgeSpec("parent", "done", "success")],
|
|
||||||
terminal_nodes=["done"],
|
|
||||||
)
|
|
||||||
|
|
||||||
restored = ExecutionGraphSpec.from_dict(spec.to_dict())
|
|
||||||
|
|
||||||
assert restored.nodes["parent"].metadata["subgraph"]["name"] == "child_graph"
|
|
||||||
|
|
||||||
|
|
||||||
def test_graph_run_inspection_exposes_ui_ready_shape() -> None:
|
|
||||||
spec = ExecutionGraphSpec(
|
|
||||||
name="inspect_fixture",
|
|
||||||
version=1,
|
|
||||||
graph_type="FIXTURE",
|
|
||||||
entry="done",
|
|
||||||
nodes={"done": GraphNodeSpec("done", "done_node")},
|
|
||||||
edges=[],
|
|
||||||
terminal_nodes=["done"],
|
|
||||||
)
|
|
||||||
graph_run = persist_spec(spec)
|
|
||||||
inspection = graph_run_inspection(graph_run)
|
|
||||||
|
|
||||||
assert inspection["graph"] == "inspect_fixture"
|
|
||||||
assert inspection["current_node"] == "done"
|
|
||||||
assert inspection["nodes"] == [{"id": "done", "type": "done_node", "status": "PENDING", "visit_index": 0, "duration_ms": None, "failure": {}, "metadata": {}}]
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from graph.langgraph_runtime import LangGraphRuntime
|
|
||||||
|
|
||||||
|
|
||||||
def test_langgraph_runtime_reports_missing_dependency() -> None:
|
|
||||||
if importlib.util.find_spec("langgraph") is not None:
|
|
||||||
pytest.skip("langgraph is installed; adapter execution is covered by integration parity tests")
|
|
||||||
|
|
||||||
runtime = LangGraphRuntime()
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="langgraph package|NodeHandlerRegistry"):
|
|
||||||
runtime.run_until_terminal_or_paused(None) # type: ignore[arg-type]
|
|
||||||
|
|
@ -85,8 +85,6 @@ def assert_accepted_trace(task: Task) -> CommitRecord:
|
||||||
assert review.status == "PASS"
|
assert review.status == "PASS"
|
||||||
assert verification.result == VerificationResult.PASS
|
assert verification.result == VerificationResult.PASS
|
||||||
assert commit.sha
|
assert commit.sha
|
||||||
assert commit.graph_run_id is not None
|
|
||||||
assert commit.graph_run.execution_graph_version.graph.name == "task_execution"
|
|
||||||
assert commit.coder.agent.role == "CODER"
|
assert commit.coder.agent.role == "CODER"
|
||||||
assert commit.reviewer.agent.role == "REVIEWER"
|
assert commit.reviewer.agent.role == "REVIEWER"
|
||||||
assert commit.judge.agent.role == "PROJECT_JUDGE"
|
assert commit.judge.agent.role == "PROJECT_JUDGE"
|
||||||
|
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from graph.models import ExecutionGraphDefinition, ExecutionGraphVersion, ExecutionGraphVersionStatus, GraphEdgeTraversal, GraphRun, GraphRunStatus
|
|
||||||
from graph.native_runtime import NativeGraphRuntime
|
|
||||||
from graph.registry import NodeHandlerRegistry, NodeResult
|
|
||||||
from graph.spec import ExecutionGraphSpec, GraphEdgeSpec, GraphNodeSpec
|
|
||||||
|
|
||||||
|
|
||||||
class FixedNode:
|
|
||||||
idempotent = True
|
|
||||||
replay_safe = True
|
|
||||||
destructive = False
|
|
||||||
|
|
||||||
def __init__(self, node_type: str, edge_result: str = "success") -> None:
|
|
||||||
self.node_type = node_type
|
|
||||||
self.edge_result = edge_result
|
|
||||||
self.calls = 0
|
|
||||||
|
|
||||||
def run(self, context: object) -> NodeResult:
|
|
||||||
self.calls += 1
|
|
||||||
return NodeResult("COMPLETE", self.edge_result, {"calls": self.calls}, {"model_requests": 1})
|
|
||||||
|
|
||||||
|
|
||||||
def persist_spec(spec: ExecutionGraphSpec) -> GraphRun:
|
|
||||||
definition = ExecutionGraphDefinition.objects.create(name=spec.name, graph_type=spec.graph_type)
|
|
||||||
version = ExecutionGraphVersion.objects.create(graph=definition, version=spec.version, status=ExecutionGraphVersionStatus.CHAMPION, graph_spec=spec.to_dict())
|
|
||||||
return GraphRun.objects.create(execution_graph_version=version, current_node=spec.entry)
|
|
||||||
|
|
||||||
|
|
||||||
def test_native_graph_runtime_executes_conditional_edges_and_persists_history() -> None:
|
|
||||||
spec = ExecutionGraphSpec(
|
|
||||||
name="fixture_graph",
|
|
||||||
version=1,
|
|
||||||
graph_type="FIXTURE",
|
|
||||||
entry="start",
|
|
||||||
nodes={
|
|
||||||
"start": GraphNodeSpec("start", "start"),
|
|
||||||
"success": GraphNodeSpec("success", "success"),
|
|
||||||
"fail": GraphNodeSpec("fail", "fail"),
|
|
||||||
},
|
|
||||||
edges=[GraphEdgeSpec("start", "success", "ok"), GraphEdgeSpec("start", "fail", "bad")],
|
|
||||||
terminal_nodes=["success", "fail"],
|
|
||||||
)
|
|
||||||
graph_run = persist_spec(spec)
|
|
||||||
registry = NodeHandlerRegistry()
|
|
||||||
start = FixedNode("start", "ok")
|
|
||||||
registry.register(start)
|
|
||||||
|
|
||||||
result = NativeGraphRuntime(registry).run_until_terminal_or_paused(graph_run)
|
|
||||||
|
|
||||||
assert result.status == GraphRunStatus.COMPLETE
|
|
||||||
assert result.current_node == "success"
|
|
||||||
assert start.calls == 1
|
|
||||||
assert list(GraphEdgeTraversal.objects.filter(graph_run=graph_run).values_list("source_node", "target_node", "condition")) == [("start", "success", "ok")]
|
|
||||||
assert graph_run.node_runs.get(node_id="start").telemetry["duration_ms"] >= 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_native_graph_runtime_resume_does_not_repeat_completed_current_node() -> None:
|
|
||||||
spec = ExecutionGraphSpec(
|
|
||||||
name="resume_graph",
|
|
||||||
version=1,
|
|
||||||
graph_type="FIXTURE",
|
|
||||||
entry="first",
|
|
||||||
nodes={
|
|
||||||
"first": GraphNodeSpec("first", "first"),
|
|
||||||
"second": GraphNodeSpec("second", "second"),
|
|
||||||
"done": GraphNodeSpec("done", "done"),
|
|
||||||
},
|
|
||||||
edges=[GraphEdgeSpec("first", "second", "success"), GraphEdgeSpec("second", "done", "success")],
|
|
||||||
terminal_nodes=["done"],
|
|
||||||
)
|
|
||||||
graph_run = persist_spec(spec)
|
|
||||||
registry = NodeHandlerRegistry()
|
|
||||||
first = FixedNode("first")
|
|
||||||
second = FixedNode("second")
|
|
||||||
registry.register(first)
|
|
||||||
registry.register(second)
|
|
||||||
runtime = NativeGraphRuntime(registry)
|
|
||||||
|
|
||||||
runtime.run_until_terminal_or_paused(graph_run, interrupt_after="first")
|
|
||||||
graph_run.refresh_from_db()
|
|
||||||
assert graph_run.current_node == "first"
|
|
||||||
|
|
||||||
runtime.run_until_terminal_or_paused(graph_run)
|
|
||||||
|
|
||||||
graph_run.refresh_from_db()
|
|
||||||
assert graph_run.status == GraphRunStatus.COMPLETE
|
|
||||||
assert first.calls == 1
|
|
||||||
assert second.calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_native_graph_runtime_records_paused_runs() -> None:
|
|
||||||
class PauseNode(FixedNode):
|
|
||||||
def run(self, context: object) -> NodeResult:
|
|
||||||
self.calls += 1
|
|
||||||
return NodeResult("PAUSED", "awaiting", pause_reason="AWAITING_APPROVAL")
|
|
||||||
|
|
||||||
spec = ExecutionGraphSpec(
|
|
||||||
name="pause_graph",
|
|
||||||
version=1,
|
|
||||||
graph_type="FIXTURE",
|
|
||||||
entry="approval",
|
|
||||||
nodes={"approval": GraphNodeSpec("approval", "approval"), "done": GraphNodeSpec("done", "done")},
|
|
||||||
edges=[GraphEdgeSpec("approval", "done", "approved")],
|
|
||||||
terminal_nodes=["done"],
|
|
||||||
)
|
|
||||||
graph_run = persist_spec(spec)
|
|
||||||
registry = NodeHandlerRegistry()
|
|
||||||
registry.register(PauseNode("approval"))
|
|
||||||
|
|
||||||
result = NativeGraphRuntime(registry).run_until_terminal_or_paused(graph_run)
|
|
||||||
|
|
||||||
assert result.status == GraphRunStatus.PAUSED
|
|
||||||
assert result.failure_reason == "AWAITING_APPROVAL"
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from agents.providers import DeterministicCodingProvider
|
|
||||||
from control_plane.agents.management.commands.seed_core_agents import Command as SeedAgentsCommand
|
|
||||||
from control_plane.events.models import Event, EventType
|
|
||||||
from control_plane.projects.models import CommitRecord, Project, ProjectPlan, Milestone, Task, TaskStatus
|
|
||||||
from control_plane.verification.models import Review, TestRun, Verification, VerificationResult
|
|
||||||
from graph.models import ExecutionGraphDefinition, ExecutionGraphVersion, ExecutionGraphVersionStatus, GraphEdgeTraversal, GraphRun, GraphRunStatus
|
|
||||||
from graph.native_runtime import NativeGraphRuntime
|
|
||||||
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 tests.test_m2_autonomous_loop import create_disposable_django_repo
|
|
||||||
|
|
||||||
|
|
||||||
def create_task(repository_path: Path, goal: str, acceptance: list[str], *, max_retries: int = 2) -> Task:
|
|
||||||
project = Project.objects.create(name=f"Graph Project {goal[:12]}", goal=goal, repository_path=str(repository_path))
|
|
||||||
plan = ProjectPlan.objects.create(project=project, version=1, goal=goal)
|
|
||||||
milestone = Milestone.objects.create(project=project, plan=plan, key="G1", title="Graph", goal="Execution graph")
|
|
||||||
return Task.objects.create(
|
|
||||||
project=project,
|
|
||||||
milestone=milestone,
|
|
||||||
task_type="implementation",
|
|
||||||
status=TaskStatus.RUNNING,
|
|
||||||
goal=goal,
|
|
||||||
acceptance_criteria=acceptance,
|
|
||||||
max_retries=max_retries,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def graph_run_for_task(task: Task) -> GraphRun:
|
|
||||||
spec = task_execution_graph_v1()
|
|
||||||
definition, _ = ExecutionGraphDefinition.objects.get_or_create(
|
|
||||||
name=spec.name,
|
|
||||||
defaults={"graph_type": spec.graph_type, "description": "Task execution graph"},
|
|
||||||
)
|
|
||||||
version, _ = ExecutionGraphVersion.objects.get_or_create(
|
|
||||||
graph=definition,
|
|
||||||
version=spec.version,
|
|
||||||
defaults={"status": ExecutionGraphVersionStatus.CHAMPION, "graph_spec": spec.to_dict()},
|
|
||||||
)
|
|
||||||
return GraphRun.objects.create(
|
|
||||||
execution_graph_version=version,
|
|
||||||
project=task.project,
|
|
||||||
milestone=task.milestone,
|
|
||||||
feature=task.feature,
|
|
||||||
task=task,
|
|
||||||
current_node=spec.entry,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_graph(task: Task, *, interrupt_after: str | None = None) -> GraphRun:
|
|
||||||
SeedAgentsCommand().handle()
|
|
||||||
services = TaskExecutionServices(ModelRouter({"qwen": 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 test_native_task_execution_graph_success_matches_loop_semantics(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_graph(task)
|
|
||||||
|
|
||||||
task.refresh_from_db()
|
|
||||||
assert graph_run.status == GraphRunStatus.COMPLETE
|
|
||||||
assert task.status == TaskStatus.COMPLETE
|
|
||||||
assert CommitRecord.objects.filter(task=task).count() == 1
|
|
||||||
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 task.worktree.status == "CLEANED"
|
|
||||||
assert GraphEdgeTraversal.objects.filter(graph_run=graph_run, source_node="judge", target_node="commit", condition="PASS").exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_native_task_execution_graph_retry_exhaustion_matches_loop_semantics(tmp_path: Path) -> None:
|
|
||||||
repo = create_disposable_django_repo(tmp_path)
|
|
||||||
task = create_task(repo, "FORCE_BAD_IMPLEMENTATION Add a /health endpoint returning JSON {\"status\": \"ok\"} and add tests.", ["/health returns JSON ok", "tests pass"], max_retries=1)
|
|
||||||
|
|
||||||
graph_run = run_graph(task)
|
|
||||||
|
|
||||||
task.refresh_from_db()
|
|
||||||
assert graph_run.status == GraphRunStatus.FAILED
|
|
||||||
assert task.status == TaskStatus.FAILED
|
|
||||||
assert task.retry_count == 2
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
def test_native_task_execution_graph_resume_after_coder_prevents_duplicate_commit(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"])
|
|
||||||
SeedAgentsCommand().handle()
|
|
||||||
graph_run = graph_run_for_task(task)
|
|
||||||
services = TaskExecutionServices(ModelRouter({"qwen": DeterministicCodingProvider()}), test_command=["python", "manage.py", "test"])
|
|
||||||
runtime = NativeGraphRuntime(task_execution_registry(services))
|
|
||||||
|
|
||||||
runtime.run_until_terminal_or_paused(graph_run, interrupt_after="coder")
|
|
||||||
graph_run.refresh_from_db()
|
|
||||||
assert graph_run.current_node == "coder"
|
|
||||||
assert task.attempts.count() == 1
|
|
||||||
|
|
||||||
runtime.run_until_terminal_or_paused(graph_run)
|
|
||||||
runtime.run_until_terminal_or_paused(graph_run)
|
|
||||||
|
|
||||||
task.refresh_from_db()
|
|
||||||
assert task.status == TaskStatus.COMPLETE
|
|
||||||
assert CommitRecord.objects.filter(task=task).count() == 1
|
|
||||||
assert graph_run.node_runs.filter(node_id="coder").count() == 1
|
|
||||||
Loading…
Add table
Reference in a new issue