feat(work): add deterministic graph harness
CI / test (push) Successful in 22s

This commit is contained in:
m
2026-08-22 13:29:43 +03:00
parent a4450348a1
commit b2f922cbbc
5 changed files with 239 additions and 2 deletions
+72
View File
@@ -0,0 +1,72 @@
package work
type GraphNodeID string
type GraphState string
type GraphNodeState string
const (
GraphDraft GraphState = "DRAFT"
GraphAdmitted GraphState = "ADMITTED"
GraphRunning GraphState = "RUNNING"
GraphPartial GraphState = "PARTIAL"
GraphVerifying GraphState = "VERIFYING"
GraphCompleted GraphState = "COMPLETED"
GraphFailed GraphState = "FAILED"
GraphUnknown GraphState = "UNKNOWN"
GraphNodeBlocked GraphNodeState = "BLOCKED_PREREQUISITE"
GraphNodeReady GraphNodeState = "READY"
GraphNodeLeased GraphNodeState = "LEASED"
GraphNodeRunning GraphNodeState = "RUNNING"
GraphNodeSuccess GraphNodeState = "SUCCEEDED"
GraphNodeFailed GraphNodeState = "FAILED"
GraphNodeCancel GraphNodeState = "CANCELLED"
GraphNodeUnknown GraphNodeState = "UNKNOWN"
)
type GraphBudget struct {
MaxParallel int
MaxAttempts int
}
type GraphNode struct {
ID GraphNodeID
IdempotencyKey string
State GraphNodeState
Attempts int
}
type GraphEdge struct {
From GraphNodeID
To GraphNodeID
}
type WorkGraph struct {
ID string
Revision int64
State GraphState
Nodes []GraphNode
Edges []GraphEdge
Budget GraphBudget
}
type ResultCandidate struct {
ID string
GraphID string
GraphRevision int64
NodeID GraphNodeID
AuthorRunID AgentRunID
ArtifactRefs []EvidenceRef
EvidenceSummary string
Summary string
}
type ConflictSet struct {
ID string
GraphID string
GraphRevision int64
NodeID GraphNodeID
CandidateIDs []string
Resolved bool
}
+2 -2
View File
@@ -1,5 +1,5 @@
{
"task_file": "dev/tasks/DEV-000130-agent-worker-recovery.json",
"task_id": "DEV-000130",
"task_file": "dev/tasks/DEV-000131-work-graph-harness.json",
"task_id": "DEV-000131",
"task_revision": 1
}
@@ -0,0 +1,23 @@
{
"task_id": "DEV-000131",
"task_revision": 1,
"architecture_revision": "ADR-0018",
"base_repo_revision": "a445034",
"risk_tier": "E",
"intent": "Implement the first deterministic Multi-Agent Work Graph harness: typed graph revision, DAG validation, budgets and dependency-aware ready-node selection.",
"requirement_ids": ["WP-400", "DAG validation", "Fan-out/fan-in foundation"],
"allowed_change_scope": {"modules": ["work-agent-execution"], "paths": ["contracts/work/graph.go", "modules/work/domain/graph.go", "tests/agent_execution/graph_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000131-work-graph-harness.json"]},
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "database/**", "adapters/**", "cmd/**", "runtime/**", ".gitea/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
"contracts": {"consume": ["ADR-0021 graph proposal", "DEV-000130 worker/recovery contracts"], "may_change": ["Work graph typed contracts and pure domain harness"]},
"semantic_owner": "work-agent-execution",
"canonical_state": ["Typed WorkGraph value only; no persistence in this harness"],
"state_transitions": ["DRAFT/ADMITTED/RUNNING graph with dependency-aware READY nodes"],
"side_effects_and_privileges": ["Pure deterministic validation; no dispatch or external mutation"],
"persistence_and_migrations": ["Persistence/scheduler implementation follows after harness"],
"failure_retry_restart": ["Cycles, duplicate IDs/keys and invalid budgets reject before scheduling"],
"assumptions": ["Graph scheduler must not bypass Agent Execution leases or verifier policy"],
"acceptance_scenarios": ["Cycle rejected", "Ready nodes deterministic", "Dependent node remains blocked until prerequisite succeeds", "Budget is validated"],
"required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"],
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"]
}
+107
View File
@@ -0,0 +1,107 @@
package domain
import (
"errors"
"sort"
"git.cin.su/m/Assistant/contracts/work"
)
var (
ErrInvalidGraph = errors.New("invalid work graph")
ErrGraphCycle = errors.New("work graph contains a cycle")
)
func ValidateWorkGraph(graph work.WorkGraph) error {
if graph.ID == "" || graph.Revision < 1 || graph.Budget.MaxParallel < 1 || graph.Budget.MaxAttempts < 1 || len(graph.Nodes) == 0 {
return ErrInvalidGraph
}
nodes := make(map[work.GraphNodeID]struct{}, len(graph.Nodes))
keys := make(map[string]struct{}, len(graph.Nodes))
for _, node := range graph.Nodes {
if node.ID == "" || node.IdempotencyKey == "" || node.Attempts < 0 {
return ErrInvalidGraph
}
if _, exists := nodes[node.ID]; exists {
return ErrInvalidGraph
}
if _, exists := keys[node.IdempotencyKey]; exists {
return ErrInvalidGraph
}
nodes[node.ID] = struct{}{}
keys[node.IdempotencyKey] = struct{}{}
}
adjacency := make(map[work.GraphNodeID][]work.GraphNodeID, len(nodes))
for _, edge := range graph.Edges {
if edge.From == "" || edge.To == "" || edge.From == edge.To {
return ErrInvalidGraph
}
if _, ok := nodes[edge.From]; !ok {
return ErrInvalidGraph
}
if _, ok := nodes[edge.To]; !ok {
return ErrInvalidGraph
}
adjacency[edge.From] = append(adjacency[edge.From], edge.To)
}
visiting := map[work.GraphNodeID]bool{}
visited := map[work.GraphNodeID]bool{}
var visit func(work.GraphNodeID) error
visit = func(node work.GraphNodeID) error {
if visiting[node] {
return ErrGraphCycle
}
if visited[node] {
return nil
}
visiting[node] = true
for _, next := range adjacency[node] {
if err := visit(next); err != nil {
return err
}
}
delete(visiting, node)
visited[node] = true
return nil
}
for node := range nodes {
if err := visit(node); err != nil {
return err
}
}
return nil
}
func ReadyGraphNodes(graph work.WorkGraph) ([]work.GraphNodeID, error) {
if err := ValidateWorkGraph(graph); err != nil {
return nil, err
}
completed := map[work.GraphNodeID]bool{}
for _, node := range graph.Nodes {
if node.State == work.GraphNodeSuccess {
completed[node.ID] = true
}
}
blockedBy := map[work.GraphNodeID][]work.GraphNodeID{}
for _, edge := range graph.Edges {
blockedBy[edge.To] = append(blockedBy[edge.To], edge.From)
}
ready := make([]work.GraphNodeID, 0)
for _, node := range graph.Nodes {
if node.State != work.GraphNodeReady && node.State != work.GraphNodeBlocked {
continue
}
dependenciesReady := true
for _, dependency := range blockedBy[node.ID] {
if !completed[dependency] {
dependenciesReady = false
break
}
}
if dependenciesReady {
ready = append(ready, node.ID)
}
}
sort.Slice(ready, func(i, j int) bool { return ready[i] < ready[j] })
return ready, nil
}
+35
View File
@@ -0,0 +1,35 @@
package agent_execution_test
import (
"testing"
"git.cin.su/m/Assistant/contracts/work"
workdomain "git.cin.su/m/Assistant/modules/work/domain"
)
func TestWorkGraphRejectsCycleAndOrdersReadyNodes(t *testing.T) {
graph := work.WorkGraph{ID: "graph-1", Revision: 1, State: work.GraphAdmitted, Budget: work.GraphBudget{MaxParallel: 2, MaxAttempts: 2}, Nodes: []work.GraphNode{
{ID: "node-b", IdempotencyKey: "key-b", State: work.GraphNodeReady},
{ID: "node-a", IdempotencyKey: "key-a", State: work.GraphNodeReady},
{ID: "node-c", IdempotencyKey: "key-c", State: work.GraphNodeBlocked},
}, Edges: []work.GraphEdge{{From: "node-a", To: "node-c"}}}
ready, err := workdomain.ReadyGraphNodes(graph)
if err != nil || len(ready) != 2 || ready[0] != "node-a" || ready[1] != "node-b" {
t.Fatalf("unexpected ready nodes: %#v %v", ready, err)
}
graph.Edges = append(graph.Edges, work.GraphEdge{From: "node-c", To: "node-a"})
if err := workdomain.ValidateWorkGraph(graph); err != workdomain.ErrGraphCycle {
t.Fatalf("expected cycle rejection, got %v", err)
}
}
func TestWorkGraphKeepsDependentNodeBlocked(t *testing.T) {
graph := work.WorkGraph{ID: "graph-1", Revision: 1, State: work.GraphRunning, Budget: work.GraphBudget{MaxParallel: 1, MaxAttempts: 1}, Nodes: []work.GraphNode{
{ID: "source", IdempotencyKey: "source-key", State: work.GraphNodeRunning},
{ID: "dependent", IdempotencyKey: "dependent-key", State: work.GraphNodeBlocked},
}, Edges: []work.GraphEdge{{From: "source", To: "dependent"}}}
ready, err := workdomain.ReadyGraphNodes(graph)
if err != nil || len(ready) != 0 {
t.Fatalf("dependent node became ready early: %#v %v", ready, err)
}
}