Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deaddbca12 | ||
|
|
b20611590a | ||
|
|
d4972afd11 | ||
|
|
60e8292c1d | ||
|
|
da251e1aa8 | ||
|
|
eac39a3fd4 | ||
|
|
1f8af98f31 | ||
|
|
ee16103e03 | ||
|
|
09d53adfd1 | ||
|
|
89694047b1 | ||
|
|
23e0525c08 | ||
|
|
45874f2186 |
@@ -263,7 +263,7 @@ func (r *WorkRepository) SaveWorkGraph(ctx context.Context, graph work.WorkGraph
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO work.graphs(graph_id,revision,state,max_parallel,max_attempts,parent_graph_id,parent_graph_revision,trigger_evidence_ref) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(graph_id,revision) DO NOTHING`, graph.ID, graph.Revision, graph.State, graph.Budget.MaxParallel, graph.Budget.MaxAttempts, graph.ParentGraphID, graph.ParentGraphRevision, graph.TriggerEvidenceRef); err != nil {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO work.graphs(graph_id,revision,state,max_parallel,max_attempts,parent_graph_id,parent_graph_revision,trigger_evidence_ref,orchestration_bound,orchestration_goal_id,orchestration_goal_state,orchestration_search_round,orchestration_max_round,orchestration_cancel_requested,orchestration_verification_ready,orchestration_unresolved) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) ON CONFLICT(graph_id,revision) DO NOTHING`, graph.ID, graph.Revision, graph.State, graph.Budget.MaxParallel, graph.Budget.MaxAttempts, graph.ParentGraphID, graph.ParentGraphRevision, graph.TriggerEvidenceRef, graph.SearchInputBound, graph.SearchInput.GoalID, graph.SearchInput.GoalState, graph.SearchInput.SearchRound, graph.SearchInput.MaxRound, graph.SearchInput.CancelRequested, graph.SearchInput.VerificationReady, graph.SearchInput.Unresolved); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, node := range graph.Nodes {
|
||||
@@ -285,7 +285,7 @@ func (r *WorkRepository) SaveWorkGraph(ctx context.Context, graph work.WorkGraph
|
||||
|
||||
func (r *WorkRepository) LoadWorkGraph(ctx context.Context, graphID string, revision int64) (work.WorkGraph, error) {
|
||||
var graph work.WorkGraph
|
||||
err := r.pool.QueryRow(ctx, `SELECT graph_id,revision,state,max_parallel,max_attempts,parent_graph_id,parent_graph_revision,trigger_evidence_ref FROM work.graphs WHERE graph_id=$1 AND revision=$2`, graphID, revision).Scan(&graph.ID, &graph.Revision, &graph.State, &graph.Budget.MaxParallel, &graph.Budget.MaxAttempts, &graph.ParentGraphID, &graph.ParentGraphRevision, &graph.TriggerEvidenceRef)
|
||||
err := r.pool.QueryRow(ctx, `SELECT graph_id,revision,state,max_parallel,max_attempts,parent_graph_id,parent_graph_revision,trigger_evidence_ref,orchestration_bound,orchestration_goal_id,orchestration_goal_state,orchestration_search_round,orchestration_max_round,orchestration_cancel_requested,orchestration_verification_ready,orchestration_unresolved FROM work.graphs WHERE graph_id=$1 AND revision=$2`, graphID, revision).Scan(&graph.ID, &graph.Revision, &graph.State, &graph.Budget.MaxParallel, &graph.Budget.MaxAttempts, &graph.ParentGraphID, &graph.ParentGraphRevision, &graph.TriggerEvidenceRef, &graph.SearchInputBound, &graph.SearchInput.GoalID, &graph.SearchInput.GoalState, &graph.SearchInput.SearchRound, &graph.SearchInput.MaxRound, &graph.SearchInput.CancelRequested, &graph.SearchInput.VerificationReady, &graph.SearchInput.Unresolved)
|
||||
if err != nil {
|
||||
return work.WorkGraph{}, fmt.Errorf("load graph: %w", err)
|
||||
}
|
||||
@@ -351,6 +351,14 @@ func (r *WorkRepository) SaveGraphSynthesis(ctx context.Context, conflict work.C
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveGraphResultCandidate(ctx context.Context, candidate work.ResultCandidate) error {
|
||||
if candidate.ID == "" || candidate.GraphID == "" || candidate.GraphRevision < 1 || candidate.NodeID == "" || candidate.AuthorRunID == "" || candidate.EvidenceSummary == "" || candidate.Summary == "" {
|
||||
return errors.New("invalid graph result candidate")
|
||||
}
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO work.graph_result_candidates(result_candidate_id,graph_id,graph_revision,node_id,author_run_id,artifact_refs,evidence_summary,summary) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(result_candidate_id) DO NOTHING`, candidate.ID, candidate.GraphID, candidate.GraphRevision, candidate.NodeID, candidate.AuthorRunID, candidate.ArtifactRefs, candidate.EvidenceSummary, candidate.Summary)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *WorkRepository) RecoverStaleClaims(ctx context.Context, nowUnixMillis, staleAfterMillis int64) (int64, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -169,6 +169,7 @@ func main() {
|
||||
mux.HandleFunc("POST /v1/task-evaluations/execute", gateway.taskEvaluationHandler)
|
||||
discovery := discoveryService{resolver: resolver, providers: discoveries, owner: identity.PrincipalID(envOr("COGNITIVE_OWNER_PRINCIPAL_ID", "domovoy-creator-principal")), now: time.Now}
|
||||
mux.HandleFunc("POST /v1/provider-discovery/{provider}", discovery.handler)
|
||||
mux.Handle("POST /v1/provider-discovery/{provider}/qualify", modelQualificationHandler(discoveries, string(envOr("COGNITIVE_OWNER_PRINCIPAL_ID", "domovoy-creator-principal"))))
|
||||
mux.HandleFunc("GET /v1/provider-discovery/status", discovery.statusHandler)
|
||||
refreshProviderDiscoveries(resolver, discovery)
|
||||
log.Printf("cognitive-worker listening on %s", addr)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/models"
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
type modelQualificationRequest struct {
|
||||
Prompt string `json:"prompt"`
|
||||
MaxOutputBytes int `json:"max_output_bytes"`
|
||||
ModelIDs []capability.ProviderModelID `json:"model_ids,omitempty"`
|
||||
}
|
||||
|
||||
type modelQualificationResult struct {
|
||||
ProviderModelID capability.ProviderModelID `json:"provider_model_id"`
|
||||
Status string `json:"status"`
|
||||
FinishReason cognitive.FinishReason `json:"finish_reason,omitempty"`
|
||||
LatencyMillis int64 `json:"latency_millis"`
|
||||
OutputObserved bool `json:"output_observed"`
|
||||
QualityAssessed bool `json:"quality_assessed"`
|
||||
Category capability.ModelRatingCategory `json:"category"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type modelQualificationResponse struct {
|
||||
Provider capability.ProviderInstanceID `json:"provider"`
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
Results []modelQualificationResult `json:"results"`
|
||||
}
|
||||
|
||||
func modelQualificationHandler(providers map[string]providerRegistration, owner string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("provider")
|
||||
registration, ok := providers[name]
|
||||
if !ok || registration.Discovery == nil || registration.Provider == nil {
|
||||
http.Error(w, "provider qualification not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var request modelQualificationRequest
|
||||
if !decodeJSON(w, r, 16<<10, &request) || request.Prompt == "" {
|
||||
return
|
||||
}
|
||||
if request.MaxOutputBytes < 1 {
|
||||
request.MaxOutputBytes = 512
|
||||
}
|
||||
if request.MaxOutputBytes > 4096 {
|
||||
http.Error(w, "max_output_bytes exceeds qualification bound", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
modelsFound, probe := registration.Discovery.Discover(r.Context())
|
||||
if probe.Health != capability.HealthAvailable || len(modelsFound) == 0 {
|
||||
http.Error(w, "provider unavailable for qualification", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if len(request.ModelIDs) > 0 {
|
||||
modelsFound = slices.DeleteFunc(modelsFound, func(model models.DiscoveredModel) bool {
|
||||
return !slices.Contains(request.ModelIDs, model.ProviderModelID)
|
||||
})
|
||||
}
|
||||
if len(modelsFound) == 0 {
|
||||
http.Error(w, "requested model not discovered", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if len(modelsFound) > 16 {
|
||||
modelsFound = modelsFound[:16]
|
||||
}
|
||||
results := qualifyModels(r.Context(), registration, owner, request.Prompt, request.MaxOutputBytes, modelsFound)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = jsonEncode(w, modelQualificationResponse{Provider: registration.ProviderInstanceID, ObservedAtUnixMillis: time.Now().UnixMilli(), Results: results})
|
||||
})
|
||||
}
|
||||
|
||||
func qualifyModels(ctx context.Context, registration providerRegistration, owner, prompt string, maxOutputBytes int, discovered []models.DiscoveredModel) []modelQualificationResult {
|
||||
results := make([]modelQualificationResult, len(discovered))
|
||||
var wg sync.WaitGroup
|
||||
for index, model := range discovered {
|
||||
wg.Add(1)
|
||||
go func(index int, model models.DiscoveredModel) {
|
||||
defer wg.Done()
|
||||
started := time.Now()
|
||||
request := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(fmt.Sprintf("qualification:%s:%d", model.ProviderModelID, started.UnixNano())), ActorID: identity.ActorID("qualification-runtime"), PrincipalID: identity.PrincipalID(owner), ScopeID: authority.ScopeID("qualification-scope"), Message: prompt, Locale: "en-US", MaxOutputBytes: maxOutputBytes, ProviderBindingID: "qualification-binding-v1", ProviderBindingRevision: 1, ProviderInstanceID: registration.ProviderInstanceID, ModelDescriptorID: capability.ModelDescriptorID("qualification-" + string(model.ProviderModelID)), ProviderModelID: model.ProviderModelID}
|
||||
response, err := registration.Provider.Advise(ctx, request)
|
||||
result := modelQualificationResult{ProviderModelID: model.ProviderModelID, LatencyMillis: time.Since(started).Milliseconds(), QualityAssessed: false, Category: capability.ModelRatingUnrated}
|
||||
if err != nil {
|
||||
result.Status = "UNAVAILABLE"
|
||||
result.Error = err.Error()
|
||||
results[index] = result
|
||||
return
|
||||
}
|
||||
result.FinishReason = response.FinishReason
|
||||
result.OutputObserved = response.AdvisoryText != ""
|
||||
if response.FinishReason == cognitive.FinishCompleted && result.OutputObserved {
|
||||
result.Status = "REACHABLE"
|
||||
} else {
|
||||
result.Status = "RESPONDED_WITHOUT_QUALIFICATION"
|
||||
}
|
||||
results[index] = result
|
||||
}(index, model)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
func jsonEncode(w http.ResponseWriter, value any) error {
|
||||
return json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/models"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
)
|
||||
|
||||
type qualificationProvider struct{}
|
||||
|
||||
func (qualificationProvider) Probe(context.Context) models.ProviderProbe {
|
||||
return models.ProviderProbe{Health: capability.HealthAvailable}
|
||||
}
|
||||
func (qualificationProvider) Discover(context.Context) ([]models.DiscoveredModel, models.ProviderProbe) {
|
||||
return []models.DiscoveredModel{{ProviderModelID: "model-a"}, {ProviderModelID: "model-b"}}, models.ProviderProbe{Health: capability.HealthAvailable}
|
||||
}
|
||||
func (qualificationProvider) Advise(_ context.Context, request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
|
||||
if err := models.ValidateAdvisoryRequest(request); err != nil {
|
||||
return cognitive.AdvisoryResponse{}, err
|
||||
}
|
||||
return cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, AdvisoryText: "reachable", FinishReason: cognitive.FinishCompleted}, nil
|
||||
}
|
||||
|
||||
func TestModelQualificationUsesDiscoveredModelsAndLeavesQualityUnrated(t *testing.T) {
|
||||
provider := qualificationProvider{}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("POST /v1/provider-discovery/{provider}/qualify", modelQualificationHandler(map[string]providerRegistration{"gpu": {ProviderInstanceID: "gpu", Provider: provider, Discovery: provider}}, "owner"))
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
request := httptest.NewRequest(http.MethodPost, server.URL+"/v1/provider-discovery/gpu/qualify", strings.NewReader(`{"prompt":"ready","max_output_bytes":128}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("qualification status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), `"status":"REACHABLE"`) || !strings.Contains(response.Body.String(), `"category":"UNRATED"`) || strings.Contains(response.Body.String(), `"quality_assessed":true`) {
|
||||
t.Fatalf("qualification overclaimed quality: %s", response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelQualificationRejectsUnboundedOutput(t *testing.T) {
|
||||
provider := qualificationProvider{}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("POST /v1/provider-discovery/{provider}/qualify", modelQualificationHandler(map[string]providerRegistration{"cpu": {ProviderInstanceID: "cpu", Provider: provider, Discovery: provider}}, "owner"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/provider-discovery/cpu/qualify", strings.NewReader(`{"prompt":"ready","max_output_bytes":8192}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unbounded qualification status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
@@ -61,18 +61,57 @@ func main() {
|
||||
}
|
||||
writeJSON(w, map[string]any{"graph": graph, "scheduled_nodes": ready})
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/orchestration/next", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request work.SolutionSearchInput
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid orchestration input", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
decision, err := application.DecideSolutionSearch(request)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid orchestration input", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, decision)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/orchestration/wave", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request work.SolutionSearchWaveRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid orchestration wave request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := application.OrchestrateSolutionSearch(r.Context(), repository, request)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid orchestration wave request", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, result)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/result-callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request work.GraphResultCallback
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid graph result callback", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
graph, err := application.RecordGraphResultCandidate(r.Context(), repository, repository, request)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid graph result callback", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, graph)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/complete", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request application.GraphCompleteRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid graph completion request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
graph, err := application.CompleteGraphNode(r.Context(), repository, request.GraphID, request.Revision, request.NodeID, request.Succeeded)
|
||||
result, err := application.CompleteGraphNodeWithReplan(r.Context(), repository, request)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, graph)
|
||||
writeJSON(w, result)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/task-evaluations/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request work.TaskEvaluationEvidenceCallback
|
||||
@@ -93,12 +132,12 @@ func main() {
|
||||
http.Error(w, "invalid graph synthesis request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
conflict, synthesis, err := application.SynthesizeGraph(r.Context(), repository, request)
|
||||
conflict, synthesis, graph, err := application.SynthesizeAndCompleteGraph(r.Context(), repository, repository, request)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"conflict": conflict, "synthesis": synthesis})
|
||||
writeJSON(w, map[string]any{"conflict": conflict, "synthesis": synthesis, "graph": graph})
|
||||
})
|
||||
addr := os.Getenv("LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
|
||||
+29
-17
@@ -21,14 +21,15 @@ const (
|
||||
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"
|
||||
GraphNodeBlocked GraphNodeState = "BLOCKED_PREREQUISITE"
|
||||
GraphNodeReady GraphNodeState = "READY"
|
||||
GraphNodeLeased GraphNodeState = "LEASED"
|
||||
GraphNodeRunning GraphNodeState = "RUNNING"
|
||||
GraphNodeVerifying GraphNodeState = "VERIFYING"
|
||||
GraphNodeSuccess GraphNodeState = "SUCCEEDED"
|
||||
GraphNodeFailed GraphNodeState = "FAILED"
|
||||
GraphNodeCancel GraphNodeState = "CANCELLED"
|
||||
GraphNodeUnknown GraphNodeState = "UNKNOWN"
|
||||
)
|
||||
|
||||
type GraphBudget struct {
|
||||
@@ -56,15 +57,17 @@ type GraphEdge struct {
|
||||
}
|
||||
|
||||
type WorkGraph struct {
|
||||
ID string `json:"id"`
|
||||
Revision int64 `json:"revision"`
|
||||
State GraphState `json:"state"`
|
||||
Nodes []GraphNode `json:"nodes"`
|
||||
Edges []GraphEdge `json:"edges"`
|
||||
Budget GraphBudget `json:"budget"`
|
||||
ParentGraphID string `json:"parent_graph_id,omitempty"`
|
||||
ParentGraphRevision int64 `json:"parent_graph_revision,omitempty"`
|
||||
TriggerEvidenceRef string `json:"trigger_evidence_ref,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Revision int64 `json:"revision"`
|
||||
State GraphState `json:"state"`
|
||||
Nodes []GraphNode `json:"nodes"`
|
||||
Edges []GraphEdge `json:"edges"`
|
||||
Budget GraphBudget `json:"budget"`
|
||||
ParentGraphID string `json:"parent_graph_id,omitempty"`
|
||||
ParentGraphRevision int64 `json:"parent_graph_revision,omitempty"`
|
||||
TriggerEvidenceRef string `json:"trigger_evidence_ref,omitempty"`
|
||||
SearchInputBound bool `json:"search_input_bound,omitempty"`
|
||||
SearchInput SolutionSearchInput `json:"search_input,omitempty"`
|
||||
}
|
||||
|
||||
// TaskEvaluationGraphRequest describes a durable fan-out. The graph contains
|
||||
@@ -110,6 +113,15 @@ type ResultCandidate struct {
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// GraphResultCallback carries one agent's attributable candidate to Work.
|
||||
// Recording a candidate never makes the node successful; the node enters
|
||||
// VERIFYING until an independent synthesis/verifier accepts it.
|
||||
type GraphResultCallback struct {
|
||||
GraphID string `json:"graph_id"`
|
||||
Revision int64 `json:"revision"`
|
||||
Candidate ResultCandidate `json:"candidate"`
|
||||
}
|
||||
|
||||
type ConflictSet struct {
|
||||
ID string `json:"id"`
|
||||
GraphID string `json:"graph_id"`
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package work
|
||||
|
||||
type OrchestrationState string
|
||||
type SearchOptionKind string
|
||||
|
||||
const (
|
||||
OrchestrationSearching OrchestrationState = "SEARCHING"
|
||||
OrchestrationExecuting OrchestrationState = "EXECUTING"
|
||||
OrchestrationVerifying OrchestrationState = "VERIFYING"
|
||||
OrchestrationWaiting OrchestrationState = "WAITING"
|
||||
OrchestrationCompleted OrchestrationState = "COMPLETED"
|
||||
OrchestrationCancelled OrchestrationState = "CANCELLED"
|
||||
|
||||
SearchOptionAgents SearchOptionKind = "AGENT_TEAM"
|
||||
SearchOptionCapability SearchOptionKind = "CAPABILITY_DISCOVERY"
|
||||
SearchOptionResearch SearchOptionKind = "TRUTH_RESEARCH"
|
||||
SearchOptionReflection SearchOptionKind = "REFLECTION_MEMORY"
|
||||
SearchOptionHuman SearchOptionKind = "HUMAN_ATTENTION"
|
||||
)
|
||||
|
||||
// SolutionSearchInput is the durable orchestration snapshot. The scheduler
|
||||
// stores the graph/goal separately; this value only decides the next bounded
|
||||
// search wave and never grants authority by itself.
|
||||
type SolutionSearchInput struct {
|
||||
GoalID GoalID
|
||||
GoalState GoalState
|
||||
SearchRound int
|
||||
MaxRound int
|
||||
CancelRequested bool
|
||||
ActiveOptionCount int
|
||||
VerificationReady bool
|
||||
Unresolved bool
|
||||
}
|
||||
|
||||
type SolutionSearchDecision struct {
|
||||
GoalID GoalID
|
||||
State OrchestrationState
|
||||
ContinueSearching bool
|
||||
NextSearchRound int
|
||||
Options []SearchOptionKind
|
||||
Reason string
|
||||
}
|
||||
|
||||
type SolutionSearchWaveRequest struct {
|
||||
Input SolutionSearchInput `json:"input"`
|
||||
GraphID string `json:"graph_id"`
|
||||
IdempotencyPrefix string `json:"idempotency_prefix"`
|
||||
Dispatches []SolutionSearchDispatch `json:"dispatches"`
|
||||
Budget GraphBudget `json:"budget"`
|
||||
ParentGraphID string `json:"parent_graph_id,omitempty"`
|
||||
ParentGraphRevision int64 `json:"parent_graph_revision,omitempty"`
|
||||
TriggerEvidenceRef string `json:"trigger_evidence_ref,omitempty"`
|
||||
NowUnixMillis int64 `json:"now_unix_millis"`
|
||||
}
|
||||
|
||||
type SolutionSearchDispatch struct {
|
||||
Option SearchOptionKind `json:"option"`
|
||||
Intent AgentDispatchIntent `json:"intent"`
|
||||
}
|
||||
|
||||
type SolutionSearchWaveResult struct {
|
||||
Decision SolutionSearchDecision `json:"decision"`
|
||||
Graph WorkGraph `json:"graph"`
|
||||
ScheduledNodeIDs []GraphNodeID `json:"scheduled_node_ids,omitempty"`
|
||||
GraphMaterialized bool `json:"graph_materialized"`
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE work_role;
|
||||
|
||||
ALTER TABLE work.graphs ADD COLUMN IF NOT EXISTS orchestration_bound BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE work.graphs ADD COLUMN IF NOT EXISTS orchestration_goal_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE work.graphs ADD COLUMN IF NOT EXISTS orchestration_goal_state TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE work.graphs ADD COLUMN IF NOT EXISTS orchestration_search_round INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE work.graphs ADD COLUMN IF NOT EXISTS orchestration_max_round INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE work.graphs ADD COLUMN IF NOT EXISTS orchestration_cancel_requested BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE work.graphs ADD COLUMN IF NOT EXISTS orchestration_verification_ready BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE work.graphs ADD COLUMN IF NOT EXISTS orchestration_unresolved BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
ALTER TABLE work.graphs DROP CONSTRAINT IF EXISTS work_graphs_orchestration_snapshot_check;
|
||||
ALTER TABLE work.graphs ADD CONSTRAINT work_graphs_orchestration_snapshot_check CHECK (
|
||||
(NOT orchestration_bound AND orchestration_goal_id = '' AND orchestration_search_round = 0 AND orchestration_max_round = 0)
|
||||
OR (orchestration_bound AND orchestration_goal_id <> '' AND orchestration_search_round >= 1 AND orchestration_max_round >= 1)
|
||||
);
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"task_file": "dev/tasks/DEV-000230-development-gate-controller.json",
|
||||
"task_id": "DEV-000230",
|
||||
"task_file": "dev/tasks/DEV-000236-qualification-locale-fix.json",
|
||||
"task_id": "DEV-000236",
|
||||
"task_revision": 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000231",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "45874f218646d7879dfcbc814782fe56bf0aa06a",
|
||||
"risk_tier": "E",
|
||||
"intent": "Provide a durable, parallel-first solution-search decision policy for Work Graph goals so unresolved work continues through independent agent, capability, research and reflection avenues instead of dead-ending after one bounded wave.",
|
||||
"requirement_ids": ["solution orchestration", "parallel agent team", "durable retry waves", "independent verification"],
|
||||
"allowed_change_scope": {"modules": ["work-graph", "work-domain"], "paths": ["contracts/work/orchestration.go", "modules/work/domain/orchestration.go", "modules/work/domain/orchestration_test.go", "modules/work/application/orchestration.go", "cmd/work-graph-runtime/main.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000231-solution-orchestrator.json", "docs/PROJECT_STATUS.md"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "deploy/**", "LLM-local/**", "modules/work/secrets/**"]},
|
||||
"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": ["Work Goal state", "durable Work Graph scheduling", "independent verification result"], "may_change": ["typed solution-search decision contract", "Work Graph orchestration decision endpoint"]},
|
||||
"semantic_owner": "Work solution orchestration policy",
|
||||
"canonical_state": ["existing Work Goal and Work Graph records; caller persists the returned wave decision"],
|
||||
"state_transitions": ["SEARCHING -> EXECUTING -> VERIFYING -> COMPLETED", "SEARCHING -> WAITING -> SEARCHING", "any unresolved state -> next durable search wave", "any active state -> CANCELLED"],
|
||||
"side_effects_and_privileges": ["pure policy decision and unauthenticated internal runtime route only; no provider call, secret access, code mutation, promotion or deployment"],
|
||||
"persistence_and_migrations": ["none; this stage returns a typed decision for the existing durable scheduler"],
|
||||
"failure_retry_restart": ["bounded wave exhaustion becomes WAITING with a new wave number and human-attention avenue; it is never represented as failure; cancellation and independently verified completion are terminal"],
|
||||
"assumptions": ["the existing Work Graph dispatcher or next integration stage will persist the decision and fan out options", "model quality and provider routing remain separate capability decisions"],
|
||||
"acceptance_scenarios": ["first unresolved goal selects all independent search avenues", "active wave remains EXECUTING", "exhausted wave continues with a higher durable round and human attention", "only cancellation or independently verified completion stops search", "invalid input is rejected", "the decision endpoint does not mutate source, secrets or production"],
|
||||
"required_tests": ["go test ./modules/work/domain ./modules/work/application ./cmd/work-graph-runtime/...", "go vet ./modules/work/domain ./modules/work/application ./cmd/work-graph-runtime/...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production health smoke"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000232",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "89694047b1e6c7729b00c20a52acb5a2487e6dd6",
|
||||
"risk_tier": "E",
|
||||
"intent": "Connect the solution-search policy to the existing durable Work Graph scheduler so each unresolved wave is materialized as parallel dispatch intents and queued nodes.",
|
||||
"requirement_ids": ["durable solution waves", "parallel fan-out", "agent/reflex orchestration", "restartable scheduling"],
|
||||
"allowed_change_scope": {"modules": ["work-graph", "work-domain"], "paths": ["contracts/work/orchestration.go", "modules/work/domain/orchestration.go", "modules/work/domain/orchestration_test.go", "modules/work/application/orchestration.go", "cmd/work-graph-runtime/main.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000232-durable-solution-wave-dispatch.json", "docs/PROJECT_STATUS.md"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "deploy/**", "LLM-local/**", "modules/work/secrets/**"]},
|
||||
"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": ["typed solution-search decision", "existing Work Graph and dispatch queue ports"], "may_change": ["solution-search wave materialization request/result", "Work Graph orchestration wave route"]},
|
||||
"semantic_owner": "Work solution orchestration and Work Graph",
|
||||
"canonical_state": ["existing work.graphs, graph_nodes, dispatch_intents and dispatch_queue stores"],
|
||||
"state_transitions": ["policy SEARCHING/WAITING -> graph ADMITTED/RUNNING", "each option -> one queued dispatch node", "human attention remains decision metadata"],
|
||||
"side_effects_and_privileges": ["durable Work-owned graph and queue writes only; no provider call, secret access, external mutation, promotion or deployment"],
|
||||
"persistence_and_migrations": ["none; existing graph and queue tables are reused"],
|
||||
"failure_retry_restart": ["existing queue idempotency and graph revision persistence govern restart; invalid wave shape is rejected; no unresolved goal is marked failed by wave exhaustion"],
|
||||
"assumptions": ["dispatch intents are supplied by the upstream authority-aware planner", "provider/model selection and execution remain worker responsibilities"],
|
||||
"acceptance_scenarios": ["four independent solution avenues materialize as four graph nodes", "all ready nodes are queued up to graph MaxParallel", "human attention is not silently executed as an agent", "terminal policy decisions do not create a graph", "invalid or duplicate options are rejected"],
|
||||
"required_tests": ["go test ./modules/work/domain ./modules/work/application ./cmd/work-graph-runtime/...", "go vet ./modules/work/domain ./modules/work/application ./cmd/work-graph-runtime/...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production health smoke"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000233",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "ee16103e03a5fd911119e2bc45e5904255ab7752",
|
||||
"risk_tier": "E",
|
||||
"intent": "Close the multi-agent Work Graph result lifecycle: persist attributable agent candidates, prevent candidate text from completing a node, and complete only after independently verified synthesis.",
|
||||
"requirement_ids": ["fan-in", "independent verifier", "result provenance", "no false completion"],
|
||||
"allowed_change_scope": {"modules": ["work-graph", "agent-execution", "postgres-work-adapter"], "paths": ["contracts/work/graph.go", "modules/work/ports/graph.go", "modules/work/application/graph_runtime.go", "adapters/postgres/work_repository.go", "cmd/work-graph-runtime/main.go", "tests/agent_execution/graph_runtime_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000233-graph-result-fanin-verification.json", "docs/PROJECT_STATUS.md"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "deploy/**", "LLM-local/**", "modules/work/secrets/**"]},
|
||||
"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": ["existing Work Graph revision store", "existing graph result candidate/synthesis tables", "agent callback evidence"], "may_change": ["typed graph result callback", "Work result callback route", "verified synthesis completion path"]},
|
||||
"semantic_owner": "Work Graph result and verification lifecycle",
|
||||
"canonical_state": ["work.graphs, work.graph_nodes, work.graph_result_candidates, work.graph_conflicts and work.graph_synthesis_candidates"],
|
||||
"state_transitions": ["agent result -> durable ResultCandidate", "candidate -> no completion", "independent verified synthesis -> graph node SUCCEEDED", "all verified nodes -> graph COMPLETED"],
|
||||
"side_effects_and_privileges": ["Work-owned candidate/graph persistence only; no provider call, secret access, external mutation, promotion or deployment"],
|
||||
"persistence_and_migrations": ["none; existing result candidate and synthesis tables are reused"],
|
||||
"failure_retry_restart": ["immutable graph revisions remain attributable; duplicate candidate IDs are idempotent at storage; missing or unverified synthesis cannot mark success"],
|
||||
"assumptions": ["the verifier supplies all competing candidates and evidence in the synthesis request", "automatic replan selection remains the next stage because a graph does not yet own a persisted goal-search snapshot"],
|
||||
"acceptance_scenarios": ["result callback persists candidate", "result callback leaves graph running and not completed", "verified independent synthesis advances the node", "verifier cannot be a producer", "fan-in completion requires all graph nodes successful"],
|
||||
"required_tests": ["go test ./modules/work/domain ./modules/work/application ./tests/agent_execution ./cmd/work-graph-runtime/...", "go vet ./modules/work/domain ./modules/work/application ./tests/agent_execution ./cmd/work-graph-runtime/...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production health smoke"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000234",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "eac39a3fd40e9f8d0752af6f9e7893cb0b03055b",
|
||||
"risk_tier": "E",
|
||||
"intent": "Persist the solution-search snapshot with every orchestration Work Graph revision and return the next durable search decision after verified graph completion.",
|
||||
"requirement_ids": ["durable goal continuity", "automatic replan signal", "restart recovery", "no invented execution authority"],
|
||||
"allowed_change_scope": {"modules": ["work-graph", "postgres-work-adapter"], "paths": ["contracts/work/graph.go", "modules/work/domain/orchestration.go", "modules/work/application/graph_runtime.go", "adapters/postgres/work_repository.go", "database/migrations/044_work_graph_orchestration_snapshot.sql", "cmd/work-graph-runtime/main.go", "tests/agent_execution/graph_runtime_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000234-orchestration-snapshot-replan.json", "docs/PROJECT_STATUS.md"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "LLM-local/**", "modules/work/secrets/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 1},
|
||||
"contracts": {"consume": ["Work Graph immutable revision store", "SolutionSearchDecision policy"], "may_change": ["orchestration snapshot fields on WorkGraph", "graph completion result with next search decision"]},
|
||||
"semantic_owner": "Work Graph orchestration continuity",
|
||||
"canonical_state": ["work.graphs revision snapshot including GoalID and search round"],
|
||||
"state_transitions": ["wave materialization persists search snapshot", "verified graph completion -> next search decision", "next decision remains nonterminal when unresolved"],
|
||||
"side_effects_and_privileges": ["Work-owned graph persistence and queue orchestration only; no model/provider call, secret access, external mutation or promotion"],
|
||||
"persistence_and_migrations": ["migration 044 adds nullable-by-contract orchestration snapshot columns with backward-compatible defaults"],
|
||||
"failure_retry_restart": ["restarting from any immutable graph revision reconstructs the same search input; missing snapshot does not invent a replan"],
|
||||
"assumptions": ["dispatch-intent discovery/materialization is supplied by the next wave planner; this stage returns the durable hand-off rather than fabricating agent authority"],
|
||||
"acceptance_scenarios": ["new solution wave persists goal and round", "verified completion returns next round decision", "cancelled or verified terminal goal does not replan", "legacy graph without snapshot completes without invented replan"],
|
||||
"required_tests": ["go test ./modules/work/domain ./modules/work/application ./tests/agent_execution ./cmd/work-graph-runtime/...", "go vet ./modules/work/domain ./modules/work/application ./tests/agent_execution ./cmd/work-graph-runtime/...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production health smoke"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP", "REQUIRES_MIGRATION_DECISION"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000235",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "60e8292c1df947b887079413b3b35ce8b4212086",
|
||||
"risk_tier": "E",
|
||||
"intent": "Add a bounded provider-neutral model qualification probe that discovers actual model IDs, invokes them in parallel, records reachability and latency, and never overclaims quality or hardware superiority.",
|
||||
"requirement_ids": ["model fleet/failover", "dynamic model discovery", "quality separated from reachability", "parallel qualification"],
|
||||
"allowed_change_scope": {"modules": ["cognitive-worker", "capability-observation"], "paths": ["cmd/cognitive-worker/main.go", "cmd/cognitive-worker/model_qualification.go", "cmd/cognitive-worker/model_qualification_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000235-model-qualification-probe.json", "docs/PROJECT_STATUS.md"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "deploy/**", "LLM-local/**", "modules/work/secrets/**"]},
|
||||
"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": ["OpenAI-compatible LocalProvider discovery/advisory adapter", "Capability model rating categories"], "may_change": ["internal bounded provider qualification route"]},
|
||||
"semantic_owner": "Capability cognitive runtime observation",
|
||||
"canonical_state": ["none; probe response is an operational observation and does not promote a binding"],
|
||||
"state_transitions": ["DISCOVERED model -> bounded probe -> REACHABLE/UNAVAILABLE/RESPONDED_WITHOUT_QUALIFICATION", "quality remains UNRATED until independent task tests"],
|
||||
"side_effects_and_privileges": ["read-only provider model discovery and bounded chat invocation; no external mutation, secret exposure, activation, promotion or routing priority"],
|
||||
"persistence_and_migrations": ["none; existing discovery/observation and promotion flows remain unchanged"],
|
||||
"failure_retry_restart": ["each probe has provider adapter timeout; one model failure does not cancel sibling probes; rerun is bounded and idempotent in effect"],
|
||||
"assumptions": ["provider credentials are resolved inside cognitive-worker protected runtime", "additional task-specific tests can be added later without changing the primary probe"],
|
||||
"acceptance_scenarios": ["actual discovered model IDs are used instead of static placeholders", "models are probed in parallel", "successful reachability reports latency", "quality is explicitly UNRATED", "GPU presence does not alter category or promotion", "output bound and model selection are constrained"],
|
||||
"required_tests": ["go test ./cmd/cognitive-worker/... ./modules/capability/domain ./adapters/models/...", "go vet ./cmd/cognitive-worker/... ./modules/capability/domain ./adapters/models/...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production health smoke"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000236",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"risk_tier": "E",
|
||||
"base_repo_revision": "b20611590a075e3574efddbdba6e1ace312d11a8",
|
||||
"intent": "Make the bounded model qualification invocation satisfy the canonical advisory contract and keep a regression test for the required locale.",
|
||||
"requirement_ids": ["model qualification smoke", "advisory contract"],
|
||||
"allowed_change_scope": {"modules": ["cognitive-worker"], "paths": ["cmd/cognitive-worker/model_qualification.go", "cmd/cognitive-worker/model_qualification_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000236-qualification-locale-fix.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "deploy/**", "LLM-local/**", "modules/work/secrets/**", "contracts/**"]},
|
||||
"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": ["Creator advisory request validation"], "may_change": []},
|
||||
"semantic_owner": "Cognitive worker qualification observation",
|
||||
"canonical_state": ["none; qualification remains an operational observation"],
|
||||
"state_transitions": ["invalid advisory invocation -> valid bounded invocation"],
|
||||
"side_effects_and_privileges": ["read-only bounded provider invocation; no promotion or routing mutation"],
|
||||
"persistence_and_migrations": ["none"],
|
||||
"failure_retry_restart": ["one bounded probe failure remains isolated"],
|
||||
"assumptions": ["provider credentials remain protected inside cognitive-worker"],
|
||||
"acceptance_scenarios": ["live qualification invocation passes advisory validation", "quality remains UNRATED"],
|
||||
"required_tests": ["go test ./cmd/cognitive-worker/...", "go vet ./cmd/cognitive-worker/...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production health smoke"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"]
|
||||
}
|
||||
@@ -24,8 +24,8 @@
|
||||
|
||||
- `COMPLETION_MATRIX = docs/evidence/audit-completion-matrix-2026-08-23-v3.json`; all seven objective areas are mapped to current repository/production proof, with external bridge, GPU promotion/quality and deep-acceptance gaps explicit.
|
||||
|
||||
- `ACTIVE_TASK = dev/tasks/DEV-000230-development-gate-controller.json revision 1`; authenticated Core now exposes Work-owned durable development gate creation and optimistic-concurrency progression. Code execution, protected-main access, automatic promotion and production mutation remain outside this controller.
|
||||
- `ACTIVE_TASK = dev/tasks/DEV-000235-model-qualification-probe.json revision 1`; cognitive-worker now exposes a bounded provider-neutral qualification probe that discovers actual model IDs, invokes them in parallel, reports reachability/latency and keeps quality `UNRATED` until independent task tests. Hardware presence does not grant promotion or priority.
|
||||
- `LINEAGE_DECISION = docs/GOVERNANCE_LINEAGE_DECISION_2026-08-22.md`; historical packets remain provenance and are not current authority.
|
||||
- `REGISTRY_STATUS = YAML and JSON semantic mirrors agree`
|
||||
- `PRODUCTION_DOMAIN = docker.cin.su`; shared docker-test is not used for this project.
|
||||
- `NEXT_WORK = qualify an independently operated Agent Execution bridge when available and complete deferred deep browser acceptance with a current signed-in session; retain local fallback and general-first task evaluation when either capability is unavailable.`
|
||||
- `NEXT_WORK = use qualification evidence in the provider-neutral Capability rating/selection loop, then continue authenticated UI acceptance, reflection recovery and governed self-development gaps.`
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
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"`
|
||||
@@ -35,6 +36,12 @@ type GraphCompleteRequest struct {
|
||||
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"`
|
||||
@@ -82,6 +89,51 @@ func SynthesizeGraph(ctx context.Context, store ports.GraphResultStore, request
|
||||
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()
|
||||
@@ -231,3 +283,27 @@ func CompleteGraphNode(ctx context.Context, store ports.GraphStore, graphID stri
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
"git.cin.su/m/Assistant/modules/work/domain"
|
||||
"git.cin.su/m/Assistant/modules/work/ports"
|
||||
"time"
|
||||
)
|
||||
|
||||
func DecideSolutionSearch(input work.SolutionSearchInput) (work.SolutionSearchDecision, error) {
|
||||
return domain.DecideSolutionSearch(input)
|
||||
}
|
||||
|
||||
func OrchestrateSolutionSearch(ctx context.Context, store ports.GraphStore, request work.SolutionSearchWaveRequest) (work.SolutionSearchWaveResult, error) {
|
||||
decision, err := domain.DecideSolutionSearch(request.Input)
|
||||
if err != nil {
|
||||
return work.SolutionSearchWaveResult{}, err
|
||||
}
|
||||
result := work.SolutionSearchWaveResult{Decision: decision}
|
||||
if !decision.ContinueSearching || decision.State == work.OrchestrationExecuting {
|
||||
return result, nil
|
||||
}
|
||||
graph, intents, err := domain.BuildSolutionSearchWave(decision, request)
|
||||
if err != nil {
|
||||
return work.SolutionSearchWaveResult{}, err
|
||||
}
|
||||
if request.NowUnixMillis < 1 {
|
||||
request.NowUnixMillis = time.Now().UnixMilli()
|
||||
}
|
||||
graph, ready, err := AdmitAndSchedule(ctx, store, GraphScheduleRequest{Graph: graph, Intents: intents, Now: request.NowUnixMillis})
|
||||
if err != nil {
|
||||
return work.SolutionSearchWaveResult{}, err
|
||||
}
|
||||
result.Graph = graph
|
||||
result.ScheduledNodeIDs = ready
|
||||
result.GraphMaterialized = true
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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
|
||||
}
|
||||
searchInput := request.Input
|
||||
searchInput.SearchRound = decision.NextSearchRound
|
||||
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, SearchInputBound: true, SearchInput: searchInput}, intents, nil
|
||||
}
|
||||
|
||||
func containsSearchOption(options []work.SearchOptionKind, target work.SearchOptionKind) bool {
|
||||
for _, option := range options {
|
||||
if option == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSolutionSearchDoesNotDeadEndAfterBudgetedWave(t *testing.T) {
|
||||
decision, err := DecideSolutionSearch(work.SolutionSearchInput{GoalID: "goal-1", GoalState: work.GoalInProgress, SearchRound: 3, MaxRound: 3, Unresolved: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !decision.ContinueSearching || decision.State != work.OrchestrationWaiting || decision.NextSearchRound != 4 {
|
||||
t.Fatalf("unexpected exhausted-wave decision: %#v", decision)
|
||||
}
|
||||
if len(decision.Options) != 5 || decision.Options[4] != work.SearchOptionHuman {
|
||||
t.Fatalf("all search avenues were not retained: %#v", decision.Options)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolutionSearchStopsOnlyForCancelOrVerifiedCompletion(t *testing.T) {
|
||||
completed, err := DecideSolutionSearch(work.SolutionSearchInput{GoalID: "goal-1", GoalState: work.GoalCompleted, VerificationReady: true, MaxRound: 1})
|
||||
if err != nil || completed.ContinueSearching || completed.State != work.OrchestrationCompleted {
|
||||
t.Fatalf("verified completion was not terminal: %#v %v", completed, err)
|
||||
}
|
||||
cancelled, err := DecideSolutionSearch(work.SolutionSearchInput{GoalID: "goal-1", GoalState: work.GoalInProgress, CancelRequested: true, MaxRound: 1})
|
||||
if err != nil || cancelled.ContinueSearching || cancelled.State != work.OrchestrationCancelled {
|
||||
t.Fatalf("cancel was not terminal: %#v %v", cancelled, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSolutionSearchWaveMaterializesParallelAgentDirections(t *testing.T) {
|
||||
decision, err := DecideSolutionSearch(work.SolutionSearchInput{GoalID: "goal-1", GoalState: work.GoalInProgress, MaxRound: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
options := []work.SearchOptionKind{work.SearchOptionAgents, work.SearchOptionCapability, work.SearchOptionResearch, work.SearchOptionReflection}
|
||||
dispatches := make([]work.SolutionSearchDispatch, 0, len(options))
|
||||
for i, option := range options {
|
||||
dispatches = append(dispatches, work.SolutionSearchDispatch{Option: option, Intent: work.AgentDispatchIntent{DispatchID: workDispatchID(i), IdempotencyKey: workDispatchID(i) + ":idem"}})
|
||||
}
|
||||
graph, intents, err := BuildSolutionSearchWave(decision, work.SolutionSearchWaveRequest{GraphID: "graph-1", IdempotencyPrefix: "goal-1", Dispatches: dispatches, Budget: work.GraphBudget{MaxParallel: 4, MaxAttempts: 2}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(graph.Nodes) != 4 || len(intents) != 4 || graph.State != work.GraphDraft {
|
||||
t.Fatalf("unexpected materialized wave: %#v %#v", graph, intents)
|
||||
}
|
||||
for i, node := range graph.Nodes {
|
||||
if node.State != work.GraphNodeBlocked || intents[i].SpecialistPurpose != string(options[i]) {
|
||||
t.Fatalf("wave direction not preserved: node=%#v intent=%#v", node, intents[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func workDispatchID(index int) string {
|
||||
return fmt.Sprintf("dispatch-%d", index)
|
||||
}
|
||||
@@ -18,6 +18,10 @@ type GraphResultStore interface {
|
||||
SaveGraphSynthesis(context.Context, work.ConflictSet, []work.ResultCandidate, work.GraphSynthesisCandidate) error
|
||||
}
|
||||
|
||||
type GraphCandidateStore interface {
|
||||
SaveGraphResultCandidate(context.Context, work.ResultCandidate) error
|
||||
}
|
||||
|
||||
// TaskEvaluationSink is a remote-capable boundary to Capability's owner.
|
||||
// Work never writes the capability schema directly.
|
||||
type TaskEvaluationSink interface {
|
||||
|
||||
@@ -12,9 +12,11 @@ import (
|
||||
)
|
||||
|
||||
type graphStore struct {
|
||||
graphs map[string]work.WorkGraph
|
||||
queued []string
|
||||
intents []string
|
||||
graphs map[string]work.WorkGraph
|
||||
queued []string
|
||||
intents []string
|
||||
candidates []work.ResultCandidate
|
||||
syntheses int
|
||||
}
|
||||
|
||||
func (s *graphStore) SaveWorkGraph(_ context.Context, graph work.WorkGraph) error {
|
||||
@@ -35,6 +37,14 @@ func (s *graphStore) EnqueueDispatch(_ context.Context, item work.AgentDispatchQ
|
||||
s.queued = append(s.queued, item.DispatchID)
|
||||
return nil
|
||||
}
|
||||
func (s *graphStore) SaveGraphResultCandidate(_ context.Context, candidate work.ResultCandidate) error {
|
||||
s.candidates = append(s.candidates, candidate)
|
||||
return nil
|
||||
}
|
||||
func (s *graphStore) SaveGraphSynthesis(_ context.Context, _ work.ConflictSet, _ []work.ResultCandidate, _ work.GraphSynthesisCandidate) error {
|
||||
s.syntheses++
|
||||
return nil
|
||||
}
|
||||
|
||||
func itoa(value int64) string {
|
||||
return strconv.FormatInt(value, 10)
|
||||
@@ -84,6 +94,34 @@ func TestSynthesizeGraphPersistsVerifiedDissent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphResultCallbackRequiresIndependentSynthesisBeforeSuccess(t *testing.T) {
|
||||
store := &graphStore{}
|
||||
graph, _, err := application.AdmitAndSchedule(context.Background(), store, application.GraphScheduleRequest{Graph: work.WorkGraph{ID: "result-graph", Revision: 1, Budget: work.GraphBudget{MaxParallel: 1, MaxAttempts: 1}, Nodes: []work.GraphNode{{ID: "node-1", DispatchID: "dispatch-1", IdempotencyKey: "node-1", State: work.GraphNodeReady}}}, Intents: []work.AgentDispatchIntent{{DispatchID: "dispatch-1"}}, Now: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
returned, err := application.RecordGraphResultCandidate(context.Background(), store, store, work.GraphResultCallback{GraphID: graph.ID, Revision: graph.Revision, Candidate: work.ResultCandidate{ID: "candidate-1", GraphID: graph.ID, GraphRevision: graph.Revision, NodeID: "node-1", AuthorRunID: "producer-1", EvidenceSummary: "evidence", Summary: "candidate"}})
|
||||
if err != nil || len(store.candidates) != 1 || returned.State != work.GraphRunning {
|
||||
t.Fatalf("candidate callback changed completion state: returned=%+v candidates=%d err=%v", returned, len(store.candidates), err)
|
||||
}
|
||||
conflict, synthesis, completed, err := application.SynthesizeAndCompleteGraph(context.Background(), store, store, application.GraphSynthesisRequest{Conflict: work.ConflictSet{ID: "conflict-1", GraphID: graph.ID, GraphRevision: graph.Revision, NodeID: "node-1", CandidateIDs: []string{"candidate-1", "candidate-2"}}, Candidates: []work.ResultCandidate{{ID: "candidate-1", GraphID: graph.ID, GraphRevision: graph.Revision, NodeID: "node-1", AuthorRunID: "producer-1", EvidenceSummary: "evidence", Summary: "candidate"}, {ID: "candidate-2", GraphID: graph.ID, GraphRevision: graph.Revision, NodeID: "node-1", AuthorRunID: "producer-2", EvidenceSummary: "evidence-2", Summary: "candidate-2"}}, SelectedCandidate: "candidate-1", VerifierRunID: "verifier-1", VerifierIdentity: "independent", EvidenceRefs: []work.EvidenceRef{"test:evidence"}, Summary: "verified", Verified: true})
|
||||
if err != nil || !conflict.Resolved || synthesis.State != work.GraphSynthesisVerified || completed.State != work.GraphCompleted {
|
||||
t.Fatalf("verified fan-in did not complete graph: conflict=%+v synthesis=%+v graph=%+v err=%v", conflict, synthesis, completed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletedOrchestrationGraphReturnsDurableNextWaveDecision(t *testing.T) {
|
||||
store := &graphStore{}
|
||||
graph, _, err := application.AdmitAndSchedule(context.Background(), store, application.GraphScheduleRequest{Graph: work.WorkGraph{ID: "replan-graph", Revision: 1, Budget: work.GraphBudget{MaxParallel: 1, MaxAttempts: 1}, SearchInputBound: true, SearchInput: work.SolutionSearchInput{GoalID: "goal-1", GoalState: work.GoalInProgress, SearchRound: 1, MaxRound: 3}, Nodes: []work.GraphNode{{ID: "node-1", DispatchID: "dispatch-1", IdempotencyKey: "node-1", State: work.GraphNodeReady}}}, Intents: []work.AgentDispatchIntent{{DispatchID: "dispatch-1"}}, Now: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := application.CompleteGraphNodeWithReplan(context.Background(), store, application.GraphCompleteRequest{GraphID: graph.ID, Revision: graph.Revision, NodeID: "node-1", Succeeded: true})
|
||||
if err != nil || !result.ReplanRequired || !result.NextSearchDecision.ContinueSearching || result.NextSearchDecision.NextSearchRound != 2 {
|
||||
t.Fatalf("missing next-wave decision: result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphSynthesisJSONUsesStableSnakeCaseContract(t *testing.T) {
|
||||
payload, err := json.Marshal(application.GraphSynthesisRequest{Conflict: work.ConflictSet{ID: "c", GraphID: "g", GraphRevision: 1, NodeID: "n", CandidateIDs: []string{"a", "b"}}, SelectedCandidate: "a", VerifierRunID: "v", VerifierIdentity: "independent", EvidenceRefs: []work.EvidenceRef{"e"}, Summary: "s", Verified: true})
|
||||
if err != nil || !strings.Contains(string(payload), `"graph_id":"g"`) || !strings.Contains(string(payload), `"selected_candidate_id":"a"`) {
|
||||
|
||||
Reference in New Issue
Block a user