feat(work): schedule task evaluation through work graph
This commit is contained in:
@@ -18,6 +18,12 @@ type GraphScheduleRequest struct {
|
||||
Now int64 `json:"now_unix_millis"`
|
||||
}
|
||||
|
||||
type TaskEvaluationScheduleRequest struct {
|
||||
Plan work.TaskEvaluationGraphRequest `json:"plan"`
|
||||
Intents []work.AgentDispatchIntent `json:"intents"`
|
||||
Now int64 `json:"now_unix_millis"`
|
||||
}
|
||||
|
||||
type GraphCompleteRequest struct {
|
||||
GraphID string `json:"graph_id"`
|
||||
Revision int64 `json:"revision"`
|
||||
@@ -105,6 +111,18 @@ func AdmitAndSchedule(ctx context.Context, store ports.GraphStore, request Graph
|
||||
return next, ready, nil
|
||||
}
|
||||
|
||||
// AdmitAndScheduleTaskEvaluation creates the durable general-first or
|
||||
// bounded-expansion fan-out before handing it to the existing graph scheduler.
|
||||
// The request path only persists/enqueues work; specialist execution happens
|
||||
// independently through the dispatch workers.
|
||||
func AdmitAndScheduleTaskEvaluation(ctx context.Context, store ports.GraphStore, request TaskEvaluationScheduleRequest) (work.WorkGraph, []work.GraphNodeID, error) {
|
||||
graph, err := domain.BuildTaskEvaluationGraph(request.Plan)
|
||||
if err != nil {
|
||||
return work.WorkGraph{}, nil, err
|
||||
}
|
||||
return AdmitAndSchedule(ctx, store, GraphScheduleRequest{Graph: graph, Intents: request.Intents, Now: request.Now})
|
||||
}
|
||||
|
||||
func CompleteGraphNode(ctx context.Context, store ports.GraphStore, graphID string, revision int64, nodeID work.GraphNodeID, success bool) (work.WorkGraph, error) {
|
||||
graph, err := store.LoadWorkGraph(ctx, graphID, revision)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
var ErrInvalidTaskEvaluationGraph = errors.New("invalid task evaluation graph request")
|
||||
|
||||
// BuildTaskEvaluationGraph turns one bounded profile into durable specialist
|
||||
// nodes. It deliberately has no provider, endpoint or hardware inputs: those
|
||||
// are resolved later by the cognitive worker for each binding.
|
||||
func BuildTaskEvaluationGraph(request work.TaskEvaluationGraphRequest) (work.WorkGraph, error) {
|
||||
if request.GraphID == "" || request.IdempotencyPrefix == "" || len(request.BindingIDs) == 0 || len(request.BindingIDs) != len(request.DispatchIDs) || request.Budget.MaxParallel < 1 || request.Budget.MaxAttempts < 1 {
|
||||
return work.WorkGraph{}, ErrInvalidTaskEvaluationGraph
|
||||
}
|
||||
if err := request.Profile.Validate(); err != nil {
|
||||
return work.WorkGraph{}, ErrInvalidTaskEvaluationGraph
|
||||
}
|
||||
if request.Profile.Stage == capability.TaskEvaluationGeneral {
|
||||
if request.ParentGraphID != "" || request.ParentGraphRevision != 0 || request.ParentGraphState != "" || request.TriggerEvidenceRef != "" {
|
||||
return work.WorkGraph{}, ErrInvalidTaskEvaluationGraph
|
||||
}
|
||||
} else {
|
||||
if request.ParentGraphID == "" || request.ParentGraphRevision < 1 || request.TriggerEvidenceRef == "" || (request.ParentGraphState != work.GraphCompleted && request.ParentGraphState != work.GraphPartial) {
|
||||
return work.WorkGraph{}, ErrInvalidTaskEvaluationGraph
|
||||
}
|
||||
if request.Profile.ParentProfileID == "" {
|
||||
return work.WorkGraph{}, ErrInvalidTaskEvaluationGraph
|
||||
}
|
||||
}
|
||||
seenBindings := make(map[capability.CapabilityBindingID]struct{}, len(request.BindingIDs))
|
||||
seenDispatches := make(map[string]struct{}, len(request.DispatchIDs))
|
||||
nodes := make([]work.GraphNode, 0, len(request.BindingIDs))
|
||||
for i, bindingID := range request.BindingIDs {
|
||||
if bindingID == "" || request.DispatchIDs[i] == "" {
|
||||
return work.WorkGraph{}, ErrInvalidTaskEvaluationGraph
|
||||
}
|
||||
if _, ok := seenBindings[bindingID]; ok {
|
||||
return work.WorkGraph{}, ErrInvalidTaskEvaluationGraph
|
||||
}
|
||||
if _, ok := seenDispatches[request.DispatchIDs[i]]; ok {
|
||||
return work.WorkGraph{}, ErrInvalidTaskEvaluationGraph
|
||||
}
|
||||
seenBindings[bindingID] = struct{}{}
|
||||
seenDispatches[request.DispatchIDs[i]] = struct{}{}
|
||||
nodes = append(nodes, work.GraphNode{
|
||||
ID: work.GraphNodeID(fmt.Sprintf("%s-node-%d", request.GraphID, i+1)),
|
||||
DispatchID: request.DispatchIDs[i],
|
||||
IdempotencyKey: fmt.Sprintf("%s:%s:%d", request.IdempotencyPrefix, bindingID, request.Profile.Revision),
|
||||
State: work.GraphNodeBlocked,
|
||||
Purpose: work.GraphNodePurposeTaskEvaluation,
|
||||
TaskProfileID: request.Profile.ID,
|
||||
BindingID: bindingID,
|
||||
CaseIDs: append([]string(nil), request.Profile.RequiredCaseIDs...),
|
||||
})
|
||||
}
|
||||
return work.WorkGraph{ID: request.GraphID, Revision: 1, State: work.GraphDraft, Nodes: nodes, Budget: request.Budget, ParentGraphID: request.ParentGraphID, ParentGraphRevision: request.ParentGraphRevision, TriggerEvidenceRef: request.TriggerEvidenceRef}, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
func evaluationGraphRequest(stage capability.TaskEvaluationProfileStage) work.TaskEvaluationGraphRequest {
|
||||
profile := capability.TaskEvaluationProfile{ID: "profile-1", Revision: 1, CapabilityID: "capability-1", Stage: stage, BenchmarkVersion: "general-v1", RequiredCaseIDs: []string{"case-a", "case-b"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
|
||||
request := work.TaskEvaluationGraphRequest{GraphID: "graph-1", Profile: profile, BindingIDs: []capability.CapabilityBindingID{"binding-a", "binding-b"}, DispatchIDs: []string{"dispatch-a", "dispatch-b"}, IdempotencyPrefix: "task-eval", Budget: work.GraphBudget{MaxParallel: 2, MaxAttempts: 2}}
|
||||
if stage == capability.TaskEvaluationAdditional {
|
||||
request.Profile.ParentProfileID = "profile-general"
|
||||
request.Profile.TriggerEvidenceRef = "artifact:unresolved-1"
|
||||
request.Profile.Stage = capability.TaskEvaluationAdditional
|
||||
request.ParentGraphID, request.ParentGraphRevision, request.ParentGraphState, request.TriggerEvidenceRef = "graph-general", 2, work.GraphPartial, "artifact:unresolved-1"
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func TestBuildTaskEvaluationGraphFansOutOneNodePerBinding(t *testing.T) {
|
||||
graph, err := BuildTaskEvaluationGraph(evaluationGraphRequest(capability.TaskEvaluationGeneral))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(graph.Nodes) != 2 || graph.Nodes[0].Purpose != work.GraphNodePurposeTaskEvaluation || graph.Nodes[0].TaskProfileID != "profile-1" || len(graph.Nodes[0].CaseIDs) != 2 {
|
||||
t.Fatalf("unexpected task graph nodes: %+v", graph.Nodes)
|
||||
}
|
||||
if graph.ParentGraphID != "" || graph.TriggerEvidenceRef != "" {
|
||||
t.Fatalf("general graph unexpectedly has expansion lineage: %+v", graph)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTaskEvaluationGraphRequiresCompletedOrPartialGeneralParent(t *testing.T) {
|
||||
request := evaluationGraphRequest(capability.TaskEvaluationAdditional)
|
||||
request.ParentGraphState = work.GraphRunning
|
||||
if _, err := BuildTaskEvaluationGraph(request); err == nil {
|
||||
t.Fatal("expected additional graph to require a finished parent graph")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTaskEvaluationGraphRejectsGeneralReplacement(t *testing.T) {
|
||||
request := evaluationGraphRequest(capability.TaskEvaluationGeneral)
|
||||
request.ParentGraphID = "old-graph"
|
||||
request.ParentGraphRevision = 2
|
||||
request.ParentGraphState = work.GraphPartial
|
||||
request.TriggerEvidenceRef = "artifact:unresolved"
|
||||
if _, err := BuildTaskEvaluationGraph(request); err == nil {
|
||||
t.Fatal("expected general graph replacement to be rejected")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user