Compare commits

...
Author SHA1 Message Date
m 1f8af98f31 feat(work): require verified graph result fan-in
CI / test (push) Successful in 50s
CI / test (pull_request) Successful in 44s
2026-08-23 18:05:23 +03:00
m ee16103e03 Merge pull request 'feat(work): materialize durable solution waves' (#209) from codex/dev-000231-solution-orchestrator into main
CI / test (push) Successful in 1m11s
feat(work): materialize durable solution waves
2026-08-23 13:59:42 +00:00
m 09d53adfd1 feat(work): materialize durable solution waves
CI / test (push) Successful in 1m7s
CI / test (pull_request) Successful in 44s
2026-08-23 16:56:58 +03:00
m 89694047b1 Merge pull request 'feat(work): add persistent solution search orchestration policy' (#208) from codex/dev-000231-solution-orchestrator into main
CI / test (push) Successful in 1m20s
feat(work): add persistent solution search orchestration policy
2026-08-23 13:48:58 +00:00
m 23e0525c08 feat(work): add persistent solution search orchestration policy
CI / test (push) Successful in 1m9s
CI / test (pull_request) Successful in 58s
2026-08-23 16:45:58 +03:00
m 45874f2186 Merge pull request 'feat: expose governed development gate controller' (#207) from codex/dev-000230-development-gate-controller into main
CI / test (push) Successful in 1m16s
feat: expose governed development gate controller
2026-08-23 13:40:04 +00:00
m ae5848d799 feat: expose governed development gate controller
CI / test (push) Successful in 1m10s
CI / test (pull_request) Successful in 1m10s
2026-08-23 16:36:48 +03:00
m ec79f19a79 Merge pull request 'feat: connect creator research to truth runtime' (#206) from codex/dev-000229-research-bridge into main
CI / test (push) Successful in 1m53s
feat: connect creator research to truth runtime
2026-08-23 13:30:35 +00:00
m f63c4bb40c feat: connect creator research to truth runtime
CI / test (push) Successful in 1m9s
CI / test (pull_request) Successful in 1m8s
2026-08-23 16:27:00 +03:00
m 22f839df99 Merge pull request 'feat: expose controlled provider discovery status' (#205) from codex/dev-000228-controlled-discovery into main
CI / test (push) Successful in 1m9s
feat: expose controlled provider discovery status
2026-08-23 12:57:48 +00:00
m c41634a065 feat: expose controlled provider discovery status
CI / test (push) Successful in 1m5s
CI / test (pull_request) Successful in 1m2s
2026-08-23 15:54:30 +03:00
m 2609f31e59 Merge pull request 'feat: expose bounded local agent executor' (#204) from codex/dev-000227-local-executor into main
CI / test (push) Successful in 1m10s
feat: expose bounded local agent executor
2026-08-23 12:48:40 +00:00
26 changed files with 877 additions and 17 deletions
+8
View File
@@ -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 {
+44
View File
@@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"net/http"
"sort"
"time"
"git.cin.su/m/Assistant/contracts/capability"
@@ -17,6 +18,10 @@ type discoveryService struct {
}
func (s discoveryService) handler(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/v1/provider-discovery/status" {
s.statusHandler(w, r)
return
}
registration, ok := s.providers[r.PathValue("provider")]
if !ok || registration.Discovery == nil {
http.Error(w, "provider discovery not found", http.StatusNotFound)
@@ -35,3 +40,42 @@ func (s discoveryService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
}
type discoveryStatus struct {
Provider string `json:"provider"`
ProviderKind capability.ProviderKind `json:"provider_kind"`
Health capability.HealthState `json:"health"`
FailureClass capability.CognitiveFailureType `json:"failure_class,omitempty"`
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
Models []discoveredModelStatus `json:"models"`
}
type discoveredModelStatus struct {
ProviderModelID capability.ProviderModelID `json:"provider_model_id"`
Fingerprint capability.ModelFingerprint `json:"fingerprint,omitempty"`
Capabilities []capability.ModelCapability `json:"capabilities,omitempty"`
}
func (s discoveryService) statusHandler(w http.ResponseWriter, r *http.Request) {
providers := make([]string, 0, len(s.providers))
for name := range s.providers {
providers = append(providers, name)
}
sort.Strings(providers)
result := make([]discoveryStatus, 0, len(providers))
for _, name := range providers {
registration := s.providers[name]
status := discoveryStatus{Provider: name, ProviderKind: registration.Kind, Health: capability.HealthUnknown, ObservedAtUnixMillis: s.now().UnixMilli(), Models: []discoveredModelStatus{}}
if registration.Discovery != nil {
models, probe := registration.Discovery.Discover(r.Context())
status.Health = probe.Health
status.FailureClass = capability.CognitiveFailureType(probe.Failure)
for _, model := range models {
status.Models = append(status.Models, discoveredModelStatus{ProviderModelID: model.ProviderModelID, Fingerprint: model.Fingerprint, Capabilities: model.Capabilities})
}
}
result = append(result, status)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
}
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.cin.su/m/Assistant/adapters/models"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/cognitive"
"git.cin.su/m/Assistant/contracts/identity"
)
type discoveryTestProvider struct{}
func (discoveryTestProvider) Probe(context.Context) models.ProviderProbe {
return models.ProviderProbe{Health: capability.HealthAvailable}
}
func (discoveryTestProvider) Discover(context.Context) ([]models.DiscoveredModel, models.ProviderProbe) {
return []models.DiscoveredModel{{ProviderModelID: "local-model", Fingerprint: "fingerprint-1", Capabilities: []capability.ModelCapability{capability.CapabilityAdvisory}}}, models.ProviderProbe{Health: capability.HealthAvailable}
}
func (discoveryTestProvider) Advise(context.Context, cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
return cognitive.AdvisoryResponse{}, nil
}
func TestDiscoveryStatusIsReadOnlyAndDoesNotExposeCredentialMetadata(t *testing.T) {
service := discoveryService{
providers: map[string]providerRegistration{"cpu": {ProviderInstanceID: "provider-1", Kind: "openai-compatible-chat-v1", CredentialRef: "credential://must-not-be-returned", Discovery: discoveryTestProvider{}}},
owner: identity.PrincipalID("owner-1"),
now: func() time.Time { return time.UnixMilli(1234) },
}
response := httptest.NewRecorder()
service.handler(response, httptest.NewRequest(http.MethodGet, "/v1/provider-discovery/status", nil))
if response.Code != http.StatusOK {
t.Fatalf("status=%d", response.Code)
}
body := response.Body.String()
var result []discoveryStatus
if err := json.NewDecoder(strings.NewReader(body)).Decode(&result); err != nil {
t.Fatal(err)
}
if len(result) != 1 || result[0].Provider != "cpu" || result[0].Health != capability.HealthAvailable || len(result[0].Models) != 1 || result[0].ObservedAtUnixMillis != 1234 {
t.Fatalf("unexpected discovery status: %+v", result)
}
if strings.Contains(body, "must-not-be-returned") || strings.Contains(body, "credential://") {
t.Fatalf("credential metadata leaked in discovery status: %s", body)
}
}
+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.HandleFunc("GET /v1/provider-discovery/status", discovery.statusHandler)
refreshProviderDiscoveries(resolver, discovery)
log.Printf("cognitive-worker listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
+32
View File
@@ -0,0 +1,32 @@
package main
import (
"errors"
"time"
"git.cin.su/m/Assistant/contracts/work"
workdomain "git.cin.su/m/Assistant/modules/work/domain"
)
var errDevelopmentGateCreate = errors.New("development gate must start at evidence collected with revision one")
func prepareDevelopmentGate(value work.DevelopmentGate, now time.Time) (work.DevelopmentGate, error) {
if value.Stage != work.DevelopmentEvidenceCollected || value.Revision != 1 {
return work.DevelopmentGate{}, errDevelopmentGateCreate
}
value.UpdatedAtUnixMillis = now.UnixMilli()
if err := workdomain.ValidateDevelopmentGate(value); err != nil {
return work.DevelopmentGate{}, err
}
return value, nil
}
func advanceDevelopmentGate(value work.DevelopmentGate, next work.DevelopmentStage, now time.Time) (work.DevelopmentGate, error) {
advanced, err := workdomain.AdvanceDevelopmentGate(value, next)
if err != nil {
return work.DevelopmentGate{}, err
}
advanced.Revision = value.Revision + 1
advanced.UpdatedAtUnixMillis = now.UnixMilli()
return advanced, nil
}
+41
View File
@@ -0,0 +1,41 @@
package main
import (
"testing"
"time"
"git.cin.su/m/Assistant/contracts/work"
)
func TestDevelopmentGateControllerRequiresSequentialVerifiedEvidence(t *testing.T) {
now := time.UnixMilli(10)
gate, err := prepareDevelopmentGate(work.DevelopmentGate{ID: "gate-1", Revision: 1, Stage: work.DevelopmentEvidenceCollected, EvidenceRefs: []string{"evidence-1"}}, now)
if err != nil {
t.Fatal(err)
}
gate.ProposalRef = "proposal-1"
gate, err = advanceDevelopmentGate(gate, work.DevelopmentProposalCreated, now)
if err != nil || gate.Revision != 2 {
t.Fatalf("proposal transition failed: %#v %v", gate, err)
}
gate.TaskPacketRef = "task-1"
gate, err = advanceDevelopmentGate(gate, work.DevelopmentTaskAdmitted, now)
if err != nil {
t.Fatal(err)
}
gate.WorktreeRef = "isolated-worktree"
gate, err = advanceDevelopmentGate(gate, work.DevelopmentIsolated, now)
if err != nil {
t.Fatal(err)
}
gate.ProducerRunRefs = []work.AgentRunID{"producer"}
gate.VerifierRunRefs = []work.AgentRunID{"verifier"}
gate.VerificationEvidence = "verification-1"
gate, err = advanceDevelopmentGate(gate, work.DevelopmentVerified, now)
if err != nil {
t.Fatal(err)
}
if _, err := advanceDevelopmentGate(gate, work.DevelopmentPromoted, now); err == nil {
t.Fatal("promotion bypassed canary")
}
}
+78
View File
@@ -116,6 +116,10 @@ func main() {
if err != nil {
log.Fatal(err)
}
researchClient, err := newResearchRuntimeClient(envOr("RESEARCH_RUNTIME_URL", "http://research-runtime:8087"), &http.Client{Timeout: 12 * time.Second})
if err != nil {
log.Fatal(err)
}
learning := learningRecorder{epistemics: epistemicsRepository, capability: capabilityRepository}
mux := http.NewServeMux()
authenticateCreator := func(w http.ResponseWriter, r *http.Request) (identity.CreatorSession, bool) {
@@ -178,6 +182,25 @@ func main() {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(session)
})
mux.HandleFunc("POST /v1/creator/research/web", func(w http.ResponseWriter, r *http.Request) {
if _, ok := authenticateCreator(w, r); !ok {
return
}
var request epistemics.WebResearchRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
http.Error(w, "invalid web research request", http.StatusBadRequest)
return
}
result, err := researchClient.EvaluateWeb(r.Context(), request)
if err != nil {
http.Error(w, "web research unavailable", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
})
mux.HandleFunc("POST /v1/creator/development/proposals", func(w http.ResponseWriter, r *http.Request) {
session, ok := authenticateCreator(w, r)
if !ok {
@@ -233,6 +256,61 @@ func main() {
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(scaffold)
})
mux.HandleFunc("POST /v1/creator/development/gates", func(w http.ResponseWriter, r *http.Request) {
if _, ok := authenticateCreator(w, r); !ok {
return
}
var gate work.DevelopmentGate
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&gate); err != nil {
http.Error(w, "invalid development gate", http.StatusBadRequest)
return
}
gate, err := prepareDevelopmentGate(gate, time.Now())
if err != nil {
http.Error(w, "development gate rejected", http.StatusUnprocessableEntity)
return
}
if err := workRepository.SaveDevelopmentGate(r.Context(), gate, 0); err != nil {
http.Error(w, "development gate conflict", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(gate)
})
mux.HandleFunc("POST /v1/creator/development/gates/{id}/advance", func(w http.ResponseWriter, r *http.Request) {
if _, ok := authenticateCreator(w, r); !ok {
return
}
var request struct {
ExpectedRevision int64 `json:"expected_revision"`
NextStage work.DevelopmentStage `json:"next_stage"`
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil || request.ExpectedRevision < 1 || request.NextStage == "" {
http.Error(w, "invalid development gate transition", http.StatusBadRequest)
return
}
gate, err := workRepository.LoadDevelopmentGate(r.Context(), r.PathValue("id"))
if err != nil || gate.Revision != request.ExpectedRevision {
http.Error(w, "development gate revision conflict", http.StatusConflict)
return
}
gate, err = advanceDevelopmentGate(gate, request.NextStage, time.Now())
if err != nil {
http.Error(w, "development gate transition rejected", http.StatusUnprocessableEntity)
return
}
if err := workRepository.SaveDevelopmentGate(r.Context(), gate, request.ExpectedRevision); err != nil {
http.Error(w, "development gate conflict", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(gate)
})
mux.HandleFunc("GET /v1/creator/focus", func(w http.ResponseWriter, r *http.Request) {
session, ok := authenticateCreator(w, r)
if !ok {
+53
View File
@@ -0,0 +1,53 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"git.cin.su/m/Assistant/contracts/epistemics"
)
type researchRuntimeClient struct {
baseURL string
client *http.Client
}
func newResearchRuntimeClient(rawURL string, client *http.Client) (researchRuntimeClient, error) {
base := strings.TrimRight(strings.TrimSpace(rawURL), "/")
if base == "" {
return researchRuntimeClient{}, fmt.Errorf("research runtime URL is empty")
}
if client == nil {
client = http.DefaultClient
}
return researchRuntimeClient{baseURL: base, client: client}, nil
}
func (c researchRuntimeClient) EvaluateWeb(ctx context.Context, request epistemics.WebResearchRequest) (epistemics.ResearchResult, error) {
payload, err := json.Marshal(request)
if err != nil {
return epistemics.ResearchResult{}, err
}
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/research/web/evaluate", bytes.NewReader(payload))
if err != nil {
return epistemics.ResearchResult{}, err
}
httpRequest.Header.Set("Content-Type", "application/json")
response, err := c.client.Do(httpRequest)
if err != nil {
return epistemics.ResearchResult{}, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return epistemics.ResearchResult{}, fmt.Errorf("research runtime status %d", response.StatusCode)
}
var result epistemics.ResearchResult
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
return epistemics.ResearchResult{}, err
}
return result, nil
}
+33
View File
@@ -0,0 +1,33 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.cin.su/m/Assistant/contracts/epistemics"
)
func TestResearchRuntimeClientForwardsBoundedWebRequest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/research/web/evaluate" || r.Header.Get("Content-Type") != "application/json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var request epistemics.WebResearchRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.URL != "https://assistant.cin.su/healthz" {
t.Fatalf("unexpected request body: %#v %v", request, err)
}
_ = json.NewEncoder(w).Encode(epistemics.ResearchResult{PlanID: request.Plan.ID, Findings: []epistemics.ResearchFinding{{PropositionKey: request.PropositionKey, State: epistemics.FindingSupported}}})
}))
defer server.Close()
client, err := newResearchRuntimeClient(server.URL, server.Client())
if err != nil {
t.Fatal(err)
}
result, err := client.EvaluateWeb(context.Background(), epistemics.WebResearchRequest{Plan: epistemics.ResearchPlan{ID: "plan-1"}, PropositionKey: "health", URL: "https://assistant.cin.su/healthz"})
if err != nil || result.PlanID != "plan-1" || len(result.Findings) != 1 || result.Findings[0].State != epistemics.FindingSupported {
t.Fatalf("unexpected result: %#v %v", result, err)
}
}
+41 -2
View File
@@ -61,6 +61,45 @@ 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 {
@@ -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 == "" {
+18 -8
View File
@@ -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 {
@@ -110,6 +111,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"`
+66
View File
@@ -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"`
}
+2 -2
View File
@@ -1,5 +1,5 @@
{
"task_file": "dev/tasks/DEV-000227-local-executor.json",
"task_id": "DEV-000227",
"task_file": "dev/tasks/DEV-000233-graph-result-fanin-verification.json",
"task_id": "DEV-000233",
"task_revision": 1
}
@@ -0,0 +1,23 @@
{
"task_id": "DEV-000228",
"task_revision": 1,
"architecture_revision": "ADR-0018",
"base_repo_revision": "2609f31e59d3914cdf4adcf23ec618705277d12a",
"risk_tier": "E",
"intent": "Expose a read-only, provider-neutral discovery status view so Domovoy can inspect available optional providers and models before requesting qualification, without exposing credential metadata or activating anything.",
"requirement_ids": ["controlled discovery", "provider qualification visibility", "optional external tools"],
"allowed_change_scope": {"modules": ["cognitive-worker", "operations-status"], "paths": ["cmd/cognitive-worker/discovery.go", "cmd/cognitive-worker/discovery_test.go", "cmd/cognitive-worker/main.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000228-controlled-discovery.json", "docs/PROJECT_STATUS.md"]},
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "contracts/**", "database/**", "deploy/**", "LLM-local/**"]},
"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 LocalProvider discovery probes", "existing capability model metadata"], "may_change": ["read-only cognitive-worker discovery status projection"]},
"semantic_owner": "cognitive-worker observation projection",
"canonical_state": ["existing Capability discovery registration remains the canonical persisted state"],
"state_transitions": ["provider probe -> observed discovery status", "discovery registration remains DISCOVERED/INACTIVE until qualification and promotion"],
"side_effects_and_privileges": ["read-only provider probe on explicit status request; no activation, mutation or credential disclosure"],
"persistence_and_migrations": ["none"],
"failure_retry_restart": ["unavailable provider is reported with health/failure state; persisted discovery remains available through existing owner"],
"assumptions": ["provider discovery adapters already enforce bounded requests", "qualification remains a separate task-evaluation and promotion step"],
"acceptance_scenarios": ["status lists registered providers deterministically", "model inventory and probe health are visible", "credential references are not returned", "status does not activate or persist a provider", "unknown provider remains absent or unavailable"],
"required_tests": ["go test ./cmd/cognitive-worker/...", "go vet ./cmd/cognitive-worker/...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production status smoke"],
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"]
}
@@ -0,0 +1,23 @@
{
"task_id": "DEV-000229",
"task_revision": 1,
"architecture_revision": "ADR-0018",
"base_repo_revision": "22f839df99d8f6abd95f00cf99d34b4b45ace692",
"risk_tier": "E",
"intent": "Connect authenticated Domovoy/Core requests to the existing bounded Truth/Research web runtime while preserving source freshness, exact citations, conflicts and read-only connector policy.",
"requirement_ids": ["Truth/Research runtime", "authenticated internet research", "source evidence"],
"allowed_change_scope": {"modules": ["core-runtime", "research-runtime-bridge"], "paths": ["cmd/core-runtime/main.go", "cmd/core-runtime/research_client.go", "cmd/core-runtime/research_client_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000229-research-runtime-bridge.json", "docs/PROJECT_STATUS.md"]},
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "contracts/**", "database/**", "deploy/**", "LLM-local/**"]},
"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 epistemics.WebResearchRequest and ResearchResult", "existing authenticated Creator session"], "may_change": ["authenticated Core-to-research runtime adapter route"]},
"semantic_owner": "epistemics research runtime; Core is an authenticated transport boundary",
"canonical_state": ["epistemics runtime remains sole owner of persisted truth bundles, claims, beliefs and citations"],
"state_transitions": ["authenticated request -> bounded web connector -> evaluated persisted finding"],
"side_effects_and_privileges": ["read-only outbound source fetches to preconfigured allowlisted hosts; authenticated user request persists evidence in epistemics owner"],
"persistence_and_migrations": ["none"],
"failure_retry_restart": ["research runtime unavailable returns explicit gateway failure; no claim is emitted by Core"],
"assumptions": ["research runtime allowlist is configured outside code", "web connector retains exact source snapshot and does not invent citations"],
"acceptance_scenarios": ["unauthenticated research request is denied", "authenticated request reaches research runtime", "source evidence and finding are returned without fabricated claims", "research runtime remains bounded by allowlist", "no secret is forwarded or returned"],
"required_tests": ["go test ./cmd/core-runtime/...", "go vet ./cmd/core-runtime/...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production health and research-runtime readiness smoke"],
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"]
}
@@ -0,0 +1,23 @@
{
"task_id": "DEV-000230",
"task_revision": 1,
"architecture_revision": "ADR-0018",
"base_repo_revision": "ec79f19a7935e1eb8f0f59c4c9ce2940b97c4855",
"risk_tier": "E",
"intent": "Expose the existing Work-owned governed development state machine through authenticated optimistic-concurrency routes without executing code, changing protected source or bypassing promotion gates.",
"requirement_ids": ["governed self-development", "durable gate progression", "canary rollback control"],
"allowed_change_scope": {"modules": ["core-runtime", "governed-development"], "paths": ["cmd/core-runtime/main.go", "cmd/core-runtime/development_gate.go", "cmd/core-runtime/development_gate_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000230-development-gate-controller.json", "docs/PROJECT_STATUS.md"]},
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "contracts/**", "database/**", "deploy/**", "LLM-local/**"]},
"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 DevelopmentGate state machine", "authenticated Creator session", "optimistic revision persistence"], "may_change": ["authenticated Core gate controller routes"]},
"semantic_owner": "Work governed development gate",
"canonical_state": ["Work-owned development gate and existing proposal/scaffold records"],
"state_transitions": ["EVIDENCE_COLLECTED -> PROPOSAL_CREATED -> TASK_ADMITTED -> ISOLATED -> VERIFIED -> CANARY -> PROMOTED", "CANARY -> ROLLED_BACK"],
"side_effects_and_privileges": ["authenticated durable gate creation/transition only; no checkout, code execution, secret access, production mutation or automatic promotion"],
"persistence_and_migrations": ["none; existing development_gates table and repository are reused"],
"failure_retry_restart": ["optimistic revision conflict is explicit; invalid or skipped transitions are rejected; rollback remains available only from canary"],
"assumptions": ["Creator authentication is the explicit governance boundary for these operator routes", "actual worktree execution and deployment remain separate controlled systems"],
"acceptance_scenarios": ["gate creation requires evidence-collected revision one", "sequential transitions require their typed references", "verified cannot jump directly to promoted", "stale revision is rejected", "canary rollback is allowed", "unauthenticated requests are denied"],
"required_tests": ["go test ./cmd/core-runtime/...", "go vet ./cmd/core-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-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"]
}
+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-000227-local-executor.json revision 1`; the existing task-evaluation path is now being exposed as a bounded local executor while unsupported work remains durably waiting.
- `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.
- `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 = 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.`
+46
View File
@@ -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"`
@@ -82,6 +83,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()
+39
View File
@@ -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
}
+91
View File
@@ -0,0 +1,91 @@
package domain
import (
"errors"
"fmt"
"git.cin.su/m/Assistant/contracts/work"
)
var ErrInvalidSolutionSearch = errors.New("invalid solution search input")
// DecideSolutionSearch keeps an unresolved goal alive across bounded waves.
// A wave has limits, but an unresolved goal is never converted into FAILED
// merely because one wave is exhausted. The caller persists the decision and
// schedules the next wave through the durable Work Graph.
func DecideSolutionSearch(input work.SolutionSearchInput) (work.SolutionSearchDecision, error) {
if input.GoalID == "" || input.SearchRound < 0 || input.MaxRound < 1 || input.ActiveOptionCount < 0 {
return work.SolutionSearchDecision{}, ErrInvalidSolutionSearch
}
decision := work.SolutionSearchDecision{GoalID: input.GoalID, NextSearchRound: input.SearchRound}
if input.CancelRequested || input.GoalState == work.GoalCancelled {
decision.State = work.OrchestrationCancelled
decision.Reason = "goal cancellation is durable and stops further search waves"
return decision, nil
}
if input.GoalState == work.GoalCompleted && input.VerificationReady {
decision.State = work.OrchestrationCompleted
decision.Reason = "goal has an independently verified accepted result"
return decision, nil
}
if input.ActiveOptionCount > 0 {
decision.State = work.OrchestrationExecuting
decision.ContinueSearching = true
decision.Options = []work.SearchOptionKind{work.SearchOptionAgents}
decision.Reason = "current solution wave is still running"
return decision, nil
}
decision.State = work.OrchestrationSearching
decision.ContinueSearching = true
decision.NextSearchRound++
decision.Options = []work.SearchOptionKind{work.SearchOptionAgents, work.SearchOptionCapability, work.SearchOptionResearch, work.SearchOptionReflection}
if input.Unresolved {
decision.Reason = "previous wave did not establish a verified solution; start all independent search avenues"
} else {
decision.Reason = "no verified result exists; start the first parallel solution wave"
}
if input.SearchRound >= input.MaxRound {
decision.State = work.OrchestrationWaiting
decision.Options = append(decision.Options, work.SearchOptionHuman)
decision.Reason = "bounded wave budget exhausted; continue with a new durable wave and human attention, never claim failure"
}
return decision, nil
}
var ErrInvalidSolutionSearchWave = errors.New("invalid solution search wave")
// BuildSolutionSearchWave turns a policy decision into a durable Work Graph
// fan-out. Human attention remains in the decision but is not an agent dispatch.
func BuildSolutionSearchWave(decision work.SolutionSearchDecision, request work.SolutionSearchWaveRequest) (work.WorkGraph, []work.AgentDispatchIntent, error) {
if !decision.ContinueSearching || (decision.State != work.OrchestrationSearching && decision.State != work.OrchestrationWaiting) || request.GraphID == "" || request.IdempotencyPrefix == "" || request.Budget.MaxParallel < 1 || request.Budget.MaxAttempts < 1 {
return work.WorkGraph{}, nil, ErrInvalidSolutionSearchWave
}
seen := make(map[work.SearchOptionKind]struct{}, len(request.Dispatches))
nodes := make([]work.GraphNode, 0, len(request.Dispatches))
intents := make([]work.AgentDispatchIntent, 0, len(request.Dispatches))
for index, dispatch := range request.Dispatches {
if dispatch.Option == work.SearchOptionHuman || dispatch.Option == "" || dispatch.Intent.DispatchID == "" || dispatch.Intent.IdempotencyKey == "" {
return work.WorkGraph{}, nil, ErrInvalidSolutionSearchWave
}
if _, exists := seen[dispatch.Option]; exists || !containsSearchOption(decision.Options, dispatch.Option) {
return work.WorkGraph{}, nil, ErrInvalidSolutionSearchWave
}
seen[dispatch.Option] = struct{}{}
intent := dispatch.Intent
intent.SpecialistPurpose = string(dispatch.Option)
intents = append(intents, intent)
nodes = append(nodes, work.GraphNode{ID: work.GraphNodeID(fmt.Sprintf("%s-node-%d", request.GraphID, index+1)), DispatchID: intent.DispatchID, IdempotencyKey: fmt.Sprintf("%s:%s:%d", request.IdempotencyPrefix, dispatch.Option, decision.NextSearchRound), State: work.GraphNodeBlocked, Purpose: work.GraphNodePurposeGeneric})
}
if len(nodes) == 0 {
return work.WorkGraph{}, nil, ErrInvalidSolutionSearchWave
}
return work.WorkGraph{ID: request.GraphID, Revision: 1, State: work.GraphDraft, Nodes: nodes, Budget: request.Budget, ParentGraphID: request.ParentGraphID, ParentGraphRevision: request.ParentGraphRevision, TriggerEvidenceRef: request.TriggerEvidenceRef}, intents, nil
}
func containsSearchOption(options []work.SearchOptionKind, target work.SearchOptionKind) bool {
for _, option := range options {
if option == target {
return true
}
}
return false
}
+59
View File
@@ -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)
}
+4
View File
@@ -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 {
+29 -3
View File
@@ -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,22 @@ 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 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"`) {