Files
Assistant/modules/work/domain/orchestration.go
m 09d53adfd1
CI / test (push) Successful in 1m7s
CI / test (pull_request) Successful in 44s
feat(work): materialize durable solution waves
2026-08-23 16:56:58 +03:00

92 lines
4.6 KiB
Go

package domain
import (
"errors"
"fmt"
"git.cin.su/m/Assistant/contracts/work"
)
var ErrInvalidSolutionSearch = errors.New("invalid solution search input")
// DecideSolutionSearch keeps an unresolved goal alive across bounded waves.
// A wave has limits, but an unresolved goal is never converted into FAILED
// merely because one wave is exhausted. The caller persists the decision and
// schedules the next wave through the durable Work Graph.
func DecideSolutionSearch(input work.SolutionSearchInput) (work.SolutionSearchDecision, error) {
if input.GoalID == "" || input.SearchRound < 0 || input.MaxRound < 1 || input.ActiveOptionCount < 0 {
return work.SolutionSearchDecision{}, ErrInvalidSolutionSearch
}
decision := work.SolutionSearchDecision{GoalID: input.GoalID, NextSearchRound: input.SearchRound}
if input.CancelRequested || input.GoalState == work.GoalCancelled {
decision.State = work.OrchestrationCancelled
decision.Reason = "goal cancellation is durable and stops further search waves"
return decision, nil
}
if input.GoalState == work.GoalCompleted && input.VerificationReady {
decision.State = work.OrchestrationCompleted
decision.Reason = "goal has an independently verified accepted result"
return decision, nil
}
if input.ActiveOptionCount > 0 {
decision.State = work.OrchestrationExecuting
decision.ContinueSearching = true
decision.Options = []work.SearchOptionKind{work.SearchOptionAgents}
decision.Reason = "current solution wave is still running"
return decision, nil
}
decision.State = work.OrchestrationSearching
decision.ContinueSearching = true
decision.NextSearchRound++
decision.Options = []work.SearchOptionKind{work.SearchOptionAgents, work.SearchOptionCapability, work.SearchOptionResearch, work.SearchOptionReflection}
if input.Unresolved {
decision.Reason = "previous wave did not establish a verified solution; start all independent search avenues"
} else {
decision.Reason = "no verified result exists; start the first parallel solution wave"
}
if input.SearchRound >= input.MaxRound {
decision.State = work.OrchestrationWaiting
decision.Options = append(decision.Options, work.SearchOptionHuman)
decision.Reason = "bounded wave budget exhausted; continue with a new durable wave and human attention, never claim failure"
}
return decision, nil
}
var ErrInvalidSolutionSearchWave = errors.New("invalid solution search wave")
// BuildSolutionSearchWave turns a policy decision into a durable Work Graph
// fan-out. Human attention remains in the decision but is not an agent dispatch.
func BuildSolutionSearchWave(decision work.SolutionSearchDecision, request work.SolutionSearchWaveRequest) (work.WorkGraph, []work.AgentDispatchIntent, error) {
if !decision.ContinueSearching || (decision.State != work.OrchestrationSearching && decision.State != work.OrchestrationWaiting) || request.GraphID == "" || request.IdempotencyPrefix == "" || request.Budget.MaxParallel < 1 || request.Budget.MaxAttempts < 1 {
return work.WorkGraph{}, nil, ErrInvalidSolutionSearchWave
}
seen := make(map[work.SearchOptionKind]struct{}, len(request.Dispatches))
nodes := make([]work.GraphNode, 0, len(request.Dispatches))
intents := make([]work.AgentDispatchIntent, 0, len(request.Dispatches))
for index, dispatch := range request.Dispatches {
if dispatch.Option == work.SearchOptionHuman || dispatch.Option == "" || dispatch.Intent.DispatchID == "" || dispatch.Intent.IdempotencyKey == "" {
return work.WorkGraph{}, nil, ErrInvalidSolutionSearchWave
}
if _, exists := seen[dispatch.Option]; exists || !containsSearchOption(decision.Options, dispatch.Option) {
return work.WorkGraph{}, nil, ErrInvalidSolutionSearchWave
}
seen[dispatch.Option] = struct{}{}
intent := dispatch.Intent
intent.SpecialistPurpose = string(dispatch.Option)
intents = append(intents, intent)
nodes = append(nodes, work.GraphNode{ID: work.GraphNodeID(fmt.Sprintf("%s-node-%d", request.GraphID, index+1)), DispatchID: intent.DispatchID, IdempotencyKey: fmt.Sprintf("%s:%s:%d", request.IdempotencyPrefix, dispatch.Option, decision.NextSearchRound), State: work.GraphNodeBlocked, Purpose: work.GraphNodePurposeGeneric})
}
if len(nodes) == 0 {
return work.WorkGraph{}, nil, ErrInvalidSolutionSearchWave
}
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}, intents, nil
}
func containsSearchOption(options []work.SearchOptionKind, target work.SearchOptionKind) bool {
for _, option := range options {
if option == target {
return true
}
}
return false
}