347 lines
14 KiB
Go
347 lines
14 KiB
Go
package application
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"git.cin.su/m/Assistant/contracts/authority"
|
|
"git.cin.su/m/Assistant/contracts/capability"
|
|
"git.cin.su/m/Assistant/contracts/identity"
|
|
"git.cin.su/m/Assistant/contracts/work"
|
|
"git.cin.su/m/Assistant/modules/work/domain"
|
|
"git.cin.su/m/Assistant/modules/work/ports"
|
|
)
|
|
|
|
var ErrGraphDispatchBinding = errors.New("graph node dispatch binding is missing")
|
|
var ErrTaskEvaluationCallbackMismatch = errors.New("task evaluation callback does not match graph node")
|
|
var ErrGraphResultCallbackMismatch = errors.New("graph result callback does not match graph node")
|
|
|
|
type GraphScheduleRequest struct {
|
|
Graph work.WorkGraph `json:"graph"`
|
|
Intents []work.AgentDispatchIntent `json:"intents"`
|
|
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"`
|
|
}
|
|
|
|
// TaskEvaluationExpansionScheduleRequest carries a typed expansion decision
|
|
// from Capability to Work. Capability owns the decision and test-case
|
|
// construction; Work owns durable graph lineage, persistence and dispatch.
|
|
type TaskEvaluationExpansionScheduleRequest struct {
|
|
Profile capability.TaskEvaluationProfile
|
|
Cases []capability.TaskEvaluationCase
|
|
ParentGraphState work.GraphState
|
|
TriggerEvidenceRef string
|
|
Plan work.TaskEvaluationGraphRequest
|
|
Intents []work.AgentDispatchIntent
|
|
Now int64
|
|
}
|
|
|
|
type GraphCompleteRequest struct {
|
|
GraphID string `json:"graph_id"`
|
|
Revision int64 `json:"revision"`
|
|
NodeID work.GraphNodeID `json:"node_id"`
|
|
Succeeded bool `json:"succeeded"`
|
|
}
|
|
|
|
type GraphCompletionResult struct {
|
|
Graph work.WorkGraph `json:"graph"`
|
|
NextSearchDecision work.SolutionSearchDecision `json:"next_search_decision"`
|
|
ReplanRequired bool `json:"replan_required"`
|
|
}
|
|
|
|
type GraphSynthesisRequest struct {
|
|
Conflict work.ConflictSet `json:"conflict"`
|
|
Candidates []work.ResultCandidate `json:"candidates"`
|
|
SelectedCandidate string `json:"selected_candidate_id"`
|
|
VerifierRunID work.AgentRunID `json:"verifier_run_id"`
|
|
VerifierIdentity string `json:"verifier_identity"`
|
|
EvidenceRefs []work.EvidenceRef `json:"evidence_refs"`
|
|
Summary string `json:"summary"`
|
|
Verified bool `json:"verified"`
|
|
}
|
|
|
|
func CompleteTaskEvaluationNode(ctx context.Context, graphStore ports.GraphStore, evidenceSink ports.TaskEvaluationSink, request work.TaskEvaluationEvidenceCallback) (work.WorkGraph, error) {
|
|
if evidenceSink == nil || request.GraphID == "" || request.Revision < 1 || request.NodeID == "" || !request.Succeeded {
|
|
return work.WorkGraph{}, ErrTaskEvaluationCallbackMismatch
|
|
}
|
|
graph, err := graphStore.LoadWorkGraph(ctx, request.GraphID, request.Revision)
|
|
if err != nil {
|
|
return work.WorkGraph{}, err
|
|
}
|
|
matched := false
|
|
for _, node := range graph.Nodes {
|
|
if node.ID != request.NodeID {
|
|
continue
|
|
}
|
|
matched = node.Purpose == work.GraphNodePurposeTaskEvaluation && node.TaskProfileID == request.Evaluation.ProfileID && node.BindingID == request.Evaluation.BindingID
|
|
break
|
|
}
|
|
if !matched || request.Evaluation.BindingRevision < 1 || request.Evaluation.CapabilityID == "" {
|
|
return work.WorkGraph{}, ErrTaskEvaluationCallbackMismatch
|
|
}
|
|
if err := evidenceSink.SaveTaskEvaluation(ctx, request.Evaluation); err != nil {
|
|
return work.WorkGraph{}, err
|
|
}
|
|
return CompleteGraphNode(ctx, graphStore, request.GraphID, request.Revision, request.NodeID, true)
|
|
}
|
|
|
|
func SynthesizeGraph(ctx context.Context, store ports.GraphResultStore, request GraphSynthesisRequest) (work.ConflictSet, work.GraphSynthesisCandidate, error) {
|
|
conflict, synthesis, err := domain.BuildVerifiedGraphSynthesis(request.Conflict, request.Candidates, request.SelectedCandidate, request.VerifierRunID, request.VerifierIdentity, request.EvidenceRefs, request.Summary, request.Verified)
|
|
if err != nil {
|
|
return work.ConflictSet{}, work.GraphSynthesisCandidate{}, err
|
|
}
|
|
if err := store.SaveGraphSynthesis(ctx, conflict, request.Candidates, synthesis); err != nil {
|
|
return work.ConflictSet{}, work.GraphSynthesisCandidate{}, err
|
|
}
|
|
return conflict, synthesis, nil
|
|
}
|
|
|
|
// RecordGraphResultCandidate persists attributable agent output. The graph is
|
|
// immutable by revision, so candidate recording does not rewrite the node;
|
|
// only verified synthesis may create the next successful graph revision.
|
|
func RecordGraphResultCandidate(ctx context.Context, candidateStore ports.GraphCandidateStore, graphStore ports.GraphStore, request work.GraphResultCallback) (work.WorkGraph, error) {
|
|
candidate := request.Candidate
|
|
if candidateStore == nil || request.GraphID == "" || request.Revision < 1 || candidate.ID == "" || candidate.GraphID != request.GraphID || candidate.GraphRevision != request.Revision || candidate.NodeID == "" || candidate.AuthorRunID == "" || candidate.EvidenceSummary == "" || candidate.Summary == "" {
|
|
return work.WorkGraph{}, ErrGraphResultCallbackMismatch
|
|
}
|
|
graph, err := graphStore.LoadWorkGraph(ctx, request.GraphID, request.Revision)
|
|
if err != nil {
|
|
return work.WorkGraph{}, err
|
|
}
|
|
found := false
|
|
for i := range graph.Nodes {
|
|
if graph.Nodes[i].ID != candidate.NodeID {
|
|
continue
|
|
}
|
|
found = true
|
|
if graph.Nodes[i].State == work.GraphNodeSuccess || graph.Nodes[i].State == work.GraphNodeCancel {
|
|
return work.WorkGraph{}, ErrGraphResultCallbackMismatch
|
|
}
|
|
}
|
|
if !found {
|
|
return work.WorkGraph{}, ErrGraphResultCallbackMismatch
|
|
}
|
|
if err := candidateStore.SaveGraphResultCandidate(ctx, candidate); err != nil {
|
|
return work.WorkGraph{}, err
|
|
}
|
|
return graph, nil
|
|
}
|
|
|
|
// SynthesizeAndCompleteGraph is the only path that turns a verifying node
|
|
// into success: synthesis must be independently verified first.
|
|
func SynthesizeAndCompleteGraph(ctx context.Context, resultStore ports.GraphResultStore, graphStore ports.GraphStore, request GraphSynthesisRequest) (work.ConflictSet, work.GraphSynthesisCandidate, work.WorkGraph, error) {
|
|
conflict, synthesis, err := SynthesizeGraph(ctx, resultStore, request)
|
|
if err != nil {
|
|
return work.ConflictSet{}, work.GraphSynthesisCandidate{}, work.WorkGraph{}, err
|
|
}
|
|
graph, err := CompleteGraphNode(ctx, graphStore, conflict.GraphID, conflict.GraphRevision, conflict.NodeID, true)
|
|
if err != nil {
|
|
return work.ConflictSet{}, work.GraphSynthesisCandidate{}, work.WorkGraph{}, err
|
|
}
|
|
return conflict, synthesis, graph, nil
|
|
}
|
|
|
|
func AdmitAndSchedule(ctx context.Context, store ports.GraphStore, request GraphScheduleRequest) (work.WorkGraph, []work.GraphNodeID, error) {
|
|
if request.Now < 1 {
|
|
request.Now = time.Now().UnixMilli()
|
|
}
|
|
admitted, err := domain.AdmitGraph(request.Graph)
|
|
if err != nil {
|
|
return work.WorkGraph{}, nil, err
|
|
}
|
|
if err := store.SaveWorkGraph(ctx, admitted); err != nil {
|
|
return work.WorkGraph{}, nil, err
|
|
}
|
|
ready, err := domain.ScheduleReadyNodes(admitted, 0)
|
|
if err != nil {
|
|
return work.WorkGraph{}, nil, err
|
|
}
|
|
intentByDispatch := make(map[string]work.AgentDispatchIntent, len(request.Intents))
|
|
for _, intent := range request.Intents {
|
|
intentByDispatch[intent.DispatchID] = intent
|
|
}
|
|
for _, node := range admitted.Nodes {
|
|
if node.DispatchID == "" {
|
|
return work.WorkGraph{}, nil, ErrGraphDispatchBinding
|
|
}
|
|
if _, ok := intentByDispatch[node.DispatchID]; !ok {
|
|
return work.WorkGraph{}, nil, ErrGraphDispatchBinding
|
|
}
|
|
}
|
|
for _, intent := range request.Intents {
|
|
if err := store.SaveDispatchIntent(ctx, intent); err != nil {
|
|
return work.WorkGraph{}, nil, err
|
|
}
|
|
}
|
|
next := admitted
|
|
next.Revision++
|
|
next.State = work.GraphRunning
|
|
selected := make(map[work.GraphNodeID]bool, len(ready))
|
|
for _, nodeID := range ready {
|
|
selected[nodeID] = true
|
|
}
|
|
for i := range next.Nodes {
|
|
if !selected[next.Nodes[i].ID] {
|
|
continue
|
|
}
|
|
if next.Nodes[i].DispatchID == "" {
|
|
return work.WorkGraph{}, nil, ErrGraphDispatchBinding
|
|
}
|
|
intent := intentByDispatch[next.Nodes[i].DispatchID]
|
|
if err := store.EnqueueDispatch(ctx, work.AgentDispatchQueueItem{DispatchID: intent.DispatchID, State: work.AgentDispatchQueued, AvailableAtUnixMillis: request.Now, UpdatedAtUnixMillis: request.Now}); err != nil {
|
|
return work.WorkGraph{}, nil, err
|
|
}
|
|
next.Nodes[i].State = work.GraphNodeLeased
|
|
}
|
|
if err := store.SaveWorkGraph(ctx, next); err != nil {
|
|
return work.WorkGraph{}, nil, err
|
|
}
|
|
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
|
|
}
|
|
intents := make([]work.AgentDispatchIntent, len(request.Intents))
|
|
copy(intents, request.Intents)
|
|
for i := range intents {
|
|
for _, node := range graph.Nodes {
|
|
if node.DispatchID != intents[i].DispatchID {
|
|
continue
|
|
}
|
|
intents[i].SpecialistPurpose = string(node.Purpose)
|
|
intents[i].TaskEvaluationProfileID = node.TaskProfileID
|
|
intents[i].TaskEvaluationBindingID = node.BindingID
|
|
intents[i].TaskEvaluationCaseIDs = append([]string(nil), node.CaseIDs...)
|
|
if node.TaskProfile != nil {
|
|
profile := *node.TaskProfile
|
|
profile.RequiredCaseIDs = append([]string(nil), node.TaskProfile.RequiredCaseIDs...)
|
|
intents[i].TaskEvaluationProfile = &profile
|
|
}
|
|
intents[i].TaskEvaluationCases = append([]capability.TaskEvaluationCase(nil), node.TaskCases...)
|
|
// AdmitAndSchedule increments the draft revision before persisting
|
|
// the leased node. The callback must target that scheduled revision;
|
|
// using the draft revision would save a completed copy that loses to
|
|
// the already-persisted immutable scheduled revision.
|
|
intents[i].TaskEvaluationExecution = &work.TaskEvaluationDispatchContext{GraphID: graph.ID, GraphRevision: graph.Revision + 1, NodeID: node.ID, BindingRevision: request.Plan.BindingRevision, ActorID: identity.ActorID(request.Plan.ActorID), PrincipalID: identity.PrincipalID(request.Plan.PrincipalID), ScopeID: authority.ScopeID(request.Plan.ScopeID)}
|
|
break
|
|
}
|
|
}
|
|
return AdmitAndSchedule(ctx, store, GraphScheduleRequest{Graph: graph, Intents: intents, Now: request.Now})
|
|
}
|
|
|
|
// ScheduleAdditionalTaskEvaluation is the runtime hand-off for the
|
|
// "general-first, additional-when-unresolved" policy. Capability decides
|
|
// whether to expand and supplies the bounded cases; this function never
|
|
// invents cases or chooses a model, and only persists/enqueues the new graph.
|
|
func ScheduleAdditionalTaskEvaluation(ctx context.Context, store ports.GraphStore, request TaskEvaluationExpansionScheduleRequest) (work.WorkGraph, []work.GraphNodeID, error) {
|
|
if request.Profile.Stage != capability.TaskEvaluationAdditional || request.Profile.ParentProfileID == "" || request.TriggerEvidenceRef == "" || len(request.Cases) == 0 {
|
|
return work.WorkGraph{}, nil, errors.New("additional task evaluation lineage is incomplete")
|
|
}
|
|
plan := request.Plan
|
|
plan.Profile = request.Profile
|
|
plan.Cases = append([]capability.TaskEvaluationCase(nil), request.Cases...)
|
|
plan.ParentGraphState = request.ParentGraphState
|
|
plan.TriggerEvidenceRef = request.TriggerEvidenceRef
|
|
graph, ready, err := AdmitAndScheduleTaskEvaluation(ctx, store, TaskEvaluationScheduleRequest{Plan: plan, Intents: request.Intents, Now: request.Now})
|
|
if err != nil {
|
|
return work.WorkGraph{}, nil, err
|
|
}
|
|
return graph, ready, nil
|
|
}
|
|
|
|
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 {
|
|
return work.WorkGraph{}, err
|
|
}
|
|
found := false
|
|
for i := range graph.Nodes {
|
|
if graph.Nodes[i].ID == nodeID {
|
|
found = true
|
|
if success {
|
|
graph.Nodes[i].State = work.GraphNodeSuccess
|
|
} else {
|
|
graph.Nodes[i].State = work.GraphNodeFailed
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
return work.WorkGraph{}, domain.ErrInvalidGraph
|
|
}
|
|
if !success {
|
|
graph.State = work.GraphFailed
|
|
} else {
|
|
ready, err := domain.ReadyGraphNodes(graph)
|
|
if err != nil {
|
|
return work.WorkGraph{}, err
|
|
}
|
|
for i := range graph.Nodes {
|
|
for _, candidate := range ready {
|
|
if graph.Nodes[i].ID == candidate && graph.Nodes[i].State == work.GraphNodeBlocked {
|
|
graph.Nodes[i].State = work.GraphNodeReady
|
|
if graph.Nodes[i].DispatchID == "" {
|
|
return work.WorkGraph{}, ErrGraphDispatchBinding
|
|
}
|
|
if err := store.EnqueueDispatch(ctx, work.AgentDispatchQueueItem{DispatchID: graph.Nodes[i].DispatchID, State: work.AgentDispatchQueued, AvailableAtUnixMillis: time.Now().UnixMilli(), UpdatedAtUnixMillis: time.Now().UnixMilli()}); err != nil {
|
|
return work.WorkGraph{}, err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
allSuccess := true
|
|
for _, node := range graph.Nodes {
|
|
if node.State != work.GraphNodeSuccess {
|
|
allSuccess = false
|
|
break
|
|
}
|
|
}
|
|
if allSuccess {
|
|
graph.State = work.GraphCompleted
|
|
} else {
|
|
graph.State = work.GraphPartial
|
|
}
|
|
}
|
|
graph.Revision++
|
|
if err := store.SaveWorkGraph(ctx, graph); err != nil {
|
|
return work.WorkGraph{}, err
|
|
}
|
|
return graph, nil
|
|
}
|
|
|
|
// CompleteGraphNodeWithReplan evaluates the persisted search snapshot after a
|
|
// verified graph completion. It does not silently invent dispatch intents;
|
|
// the returned decision is the durable hand-off to the next wave planner.
|
|
func CompleteGraphNodeWithReplan(ctx context.Context, store ports.GraphStore, request GraphCompleteRequest) (GraphCompletionResult, error) {
|
|
graph, err := CompleteGraphNode(ctx, store, request.GraphID, request.Revision, request.NodeID, request.Succeeded)
|
|
if err != nil {
|
|
return GraphCompletionResult{}, err
|
|
}
|
|
result := GraphCompletionResult{Graph: graph}
|
|
if !graph.SearchInputBound || graph.State != work.GraphCompleted {
|
|
return result, nil
|
|
}
|
|
input := graph.SearchInput
|
|
input.ActiveOptionCount = 0
|
|
input.Unresolved = true
|
|
decision, err := DecideSolutionSearch(input)
|
|
if err != nil {
|
|
return GraphCompletionResult{}, err
|
|
}
|
|
result.NextSearchDecision = decision
|
|
result.ReplanRequired = decision.ContinueSearching
|
|
return result, nil
|
|
}
|