Compare commits

...
Author SHA1 Message Date
m d4972afd11 feat(cognitive): add bounded model qualification probe
CI / test (push) Successful in 58s
CI / test (pull_request) Successful in 45s
2026-08-23 18:27:22 +03:00
m 60e8292c1d Merge pull request 'feat(work): persist orchestration snapshot for replan' (#211) from codex/dev-000234-orchestration-snapshot-replan into main
CI / test (push) Successful in 1m15s
feat(work): persist orchestration snapshot for replan
2026-08-23 15:16:43 +00:00
m da251e1aa8 feat(work): persist orchestration snapshot for replan
CI / test (push) Successful in 1m8s
CI / test (pull_request) Successful in 1m11s
2026-08-23 18:13:07 +03:00
m eac39a3fd4 Merge pull request 'feat(work): require verified graph result fan-in' (#210) from codex/dev-000233-graph-result-fanin into main
CI / test (push) Successful in 1m0s
feat(work): require verified graph result fan-in
2026-08-23 15:07:44 +00:00
14 changed files with 305 additions and 18 deletions
+2 -2
View File
@@ -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)
}
+1
View File
@@ -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)
+117
View File
@@ -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, 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,56 @@
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) {
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)
}
}
+2 -2
View File
@@ -106,12 +106,12 @@ func main() {
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
+11 -9
View File
@@ -57,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
@@ -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;
+2 -2
View File
@@ -1,5 +1,5 @@
{
"task_file": "dev/tasks/DEV-000233-graph-result-fanin-verification.json",
"task_id": "DEV-000233",
"task_file": "dev/tasks/DEV-000235-model-qualification-probe.json",
"task_id": "DEV-000235",
"task_revision": 1
}
@@ -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"]
}
+2 -2
View File
@@ -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-000233-graph-result-fanin-verification.json revision 1`; agent result callbacks now persist attributable candidates without false completion, and only independently verified synthesis advances a graph node to success and allows fan-in completion. Automatic goal-snapshot replan after a completed wave remains the next integration gap.
- `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 = persist the goal/search snapshot with each graph revision and connect verified graph completion to automatic replan/materialization; then add bounded recovery of stale waves.`
- `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.`
+30
View File
@@ -36,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"`
@@ -277,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
}
+3 -1
View File
@@ -78,7 +78,9 @@ func BuildSolutionSearchWave(decision work.SolutionSearchDecision, request work.
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
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 {
@@ -110,6 +110,18 @@ func TestGraphResultCallbackRequiresIndependentSynthesisBeforeSuccess(t *testing
}
}
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"`) {