Compare commits

...
Author SHA1 Message Date
m 7afe3ff48f feat(work): accept task evaluation evidence callbacks
CI / test (push) Successful in 1m7s
CI / test (pull_request) Successful in 1m4s
2026-08-23 12:28:42 +03:00
m 6dec47c791 Merge pull request 'feat: persist task evaluation worker context' (#187) from codex/wp-210-task-evaluator-worker into main
CI / test (push) Successful in 1m2s
2026-08-23 09:20:26 +00:00
m def0e4aff0 feat(work): persist task evaluation worker context
CI / test (push) Successful in 1m8s
CI / test (pull_request) Successful in 59s
2026-08-23 12:18:53 +03:00
m 9a6ab6a9ca Merge pull request 'feat: schedule task evaluation through Work Graph' (#186) from codex/wp-209-task-evaluator into main
CI / test (push) Successful in 1m1s
2026-08-23 09:10:32 +00:00
14 changed files with 384 additions and 23 deletions
+14 -9
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"time"
"git.cin.su/m/Assistant/contracts/work"
@@ -106,24 +107,28 @@ func (r *WorkRepository) SaveDispatchIntent(ctx context.Context, intent work.Age
return err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `INSERT INTO work.agent_dispatch_intents(dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT(idempotency_key) DO NOTHING`, intent.DispatchID, intent.IdempotencyKey, intent.DelegationID, intent.RunID, intent.ImplementationID, intent.ImplementationRevision, intent.SourceRevision, intent.WorktreeID, intent.VerificationPlanRef, intent.CreatedAtUnixMillis)
_, err = tx.Exec(ctx, `INSERT INTO work.agent_dispatch_intents(dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis,specialist_purpose,task_evaluation_profile_id,task_evaluation_binding_id,task_evaluation_case_ids) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT(idempotency_key) DO NOTHING`, intent.DispatchID, intent.IdempotencyKey, intent.DelegationID, intent.RunID, intent.ImplementationID, intent.ImplementationRevision, intent.SourceRevision, intent.WorktreeID, intent.VerificationPlanRef, intent.CreatedAtUnixMillis, intent.SpecialistPurpose, intent.TaskEvaluationProfileID, intent.TaskEvaluationBindingID, intent.TaskEvaluationCaseIDs)
if err != nil {
return fmt.Errorf("save dispatch intent: %w", err)
}
var existing work.AgentDispatchIntent
err = tx.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis FROM work.agent_dispatch_intents WHERE idempotency_key=$1`, intent.IdempotencyKey).Scan(&existing.DispatchID, &existing.IdempotencyKey, &existing.DelegationID, &existing.RunID, &existing.ImplementationID, &existing.ImplementationRevision, &existing.SourceRevision, &existing.WorktreeID, &existing.VerificationPlanRef, &existing.CreatedAtUnixMillis)
err = tx.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis,specialist_purpose,task_evaluation_profile_id,task_evaluation_binding_id,task_evaluation_case_ids FROM work.agent_dispatch_intents WHERE idempotency_key=$1`, intent.IdempotencyKey).Scan(&existing.DispatchID, &existing.IdempotencyKey, &existing.DelegationID, &existing.RunID, &existing.ImplementationID, &existing.ImplementationRevision, &existing.SourceRevision, &existing.WorktreeID, &existing.VerificationPlanRef, &existing.CreatedAtUnixMillis, &existing.SpecialistPurpose, &existing.TaskEvaluationProfileID, &existing.TaskEvaluationBindingID, &existing.TaskEvaluationCaseIDs)
if err != nil {
return fmt.Errorf("read dispatch intent: %w", err)
}
if existing != intent {
if !sameDispatchIntent(existing, intent) {
return ErrDispatchIntentConflict
}
return tx.Commit(ctx)
}
func sameDispatchIntent(left, right work.AgentDispatchIntent) bool {
return left.DispatchID == right.DispatchID && left.IdempotencyKey == right.IdempotencyKey && left.DelegationID == right.DelegationID && left.RunID == right.RunID && left.ImplementationID == right.ImplementationID && left.ImplementationRevision == right.ImplementationRevision && left.SourceRevision == right.SourceRevision && left.WorktreeID == right.WorktreeID && left.VerificationPlanRef == right.VerificationPlanRef && left.CreatedAtUnixMillis == right.CreatedAtUnixMillis && left.SpecialistPurpose == right.SpecialistPurpose && left.TaskEvaluationProfileID == right.TaskEvaluationProfileID && left.TaskEvaluationBindingID == right.TaskEvaluationBindingID && slices.Equal(left.TaskEvaluationCaseIDs, right.TaskEvaluationCaseIDs)
}
func (r *WorkRepository) LoadDispatchIntent(ctx context.Context, dispatchID string) (work.AgentDispatchIntent, error) {
var intent work.AgentDispatchIntent
err := r.pool.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis FROM work.agent_dispatch_intents WHERE dispatch_id=$1`, dispatchID).Scan(&intent.DispatchID, &intent.IdempotencyKey, &intent.DelegationID, &intent.RunID, &intent.ImplementationID, &intent.ImplementationRevision, &intent.SourceRevision, &intent.WorktreeID, &intent.VerificationPlanRef, &intent.CreatedAtUnixMillis)
err := r.pool.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis,specialist_purpose,task_evaluation_profile_id,task_evaluation_binding_id,task_evaluation_case_ids FROM work.agent_dispatch_intents WHERE dispatch_id=$1`, dispatchID).Scan(&intent.DispatchID, &intent.IdempotencyKey, &intent.DelegationID, &intent.RunID, &intent.ImplementationID, &intent.ImplementationRevision, &intent.SourceRevision, &intent.WorktreeID, &intent.VerificationPlanRef, &intent.CreatedAtUnixMillis, &intent.SpecialistPurpose, &intent.TaskEvaluationProfileID, &intent.TaskEvaluationBindingID, &intent.TaskEvaluationCaseIDs)
if err != nil {
return work.AgentDispatchIntent{}, fmt.Errorf("load dispatch intent: %w", err)
}
@@ -191,11 +196,11 @@ 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) VALUES($1,$2,$3,$4,$5) ON CONFLICT(graph_id,revision) DO NOTHING`, graph.ID, graph.Revision, graph.State, graph.Budget.MaxParallel, graph.Budget.MaxAttempts); 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) 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 {
return err
}
for _, node := range graph.Nodes {
if _, err := tx.Exec(ctx, `INSERT INTO work.graph_nodes(graph_id,revision,node_id,dispatch_id,idempotency_key,state,attempts) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(graph_id,revision,node_id) DO NOTHING`, graph.ID, graph.Revision, node.ID, node.DispatchID, node.IdempotencyKey, node.State, node.Attempts); err != nil {
if _, err := tx.Exec(ctx, `INSERT INTO work.graph_nodes(graph_id,revision,node_id,dispatch_id,idempotency_key,state,attempts,purpose,task_profile_id,binding_id,case_ids) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT(graph_id,revision,node_id) DO NOTHING`, graph.ID, graph.Revision, node.ID, node.DispatchID, node.IdempotencyKey, node.State, node.Attempts, node.Purpose, node.TaskProfileID, node.BindingID, node.CaseIDs); err != nil {
return err
}
}
@@ -209,17 +214,17 @@ 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 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)
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)
if err != nil {
return work.WorkGraph{}, fmt.Errorf("load graph: %w", err)
}
rows, err := r.pool.Query(ctx, `SELECT node_id,dispatch_id,idempotency_key,state,attempts FROM work.graph_nodes WHERE graph_id=$1 AND revision=$2 ORDER BY node_id`, graphID, revision)
rows, err := r.pool.Query(ctx, `SELECT node_id,dispatch_id,idempotency_key,state,attempts,purpose,task_profile_id,binding_id,case_ids FROM work.graph_nodes WHERE graph_id=$1 AND revision=$2 ORDER BY node_id`, graphID, revision)
if err != nil {
return work.WorkGraph{}, err
}
for rows.Next() {
var node work.GraphNode
if err := rows.Scan(&node.ID, &node.DispatchID, &node.IdempotencyKey, &node.State, &node.Attempts); err != nil {
if err := rows.Scan(&node.ID, &node.DispatchID, &node.IdempotencyKey, &node.State, &node.Attempts, &node.Purpose, &node.TaskProfileID, &node.BindingID, &node.CaseIDs); err != nil {
rows.Close()
return work.WorkGraph{}, err
}
+26
View File
@@ -7,9 +7,11 @@ import (
"log"
"net/http"
"os"
"time"
"git.cin.su/m/Assistant/adapters/postgres"
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
"git.cin.su/m/Assistant/contracts/work"
"git.cin.su/m/Assistant/modules/work/application"
"git.cin.su/m/Assistant/platform/secrets"
"git.cin.su/m/Assistant/runtime/health"
@@ -26,6 +28,10 @@ func main() {
log.Fatal(err)
}
defer repository.Close()
evidenceSink, err := newCoreTaskEvaluationSink(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), &http.Client{Timeout: 12 * time.Second})
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("/healthz", health.Handler{Runtime: "work-graph-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "work-graph-runtime", Probes: []health.Probe{{Name: "work-postgres", Check: func(ctx context.Context) error { return repository.Ping(ctx) }}}})
@@ -68,6 +74,19 @@ func main() {
}
writeJSON(w, graph)
})
mux.HandleFunc("POST /v1/work-graphs/task-evaluations/callback", func(w http.ResponseWriter, r *http.Request) {
var request work.TaskEvaluationEvidenceCallback
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
http.Error(w, "invalid task evaluation callback", http.StatusBadRequest)
return
}
graph, err := application.CompleteTaskEvaluationNode(r.Context(), repository, evidenceSink, request)
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
writeJSON(w, graph)
})
mux.HandleFunc("POST /v1/work-graphs/synthesis", func(w http.ResponseWriter, r *http.Request) {
var request application.GraphSynthesisRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&request); err != nil {
@@ -89,6 +108,13 @@ func main() {
log.Fatal(http.ListenAndServe(addr, mux))
}
func envOr(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func writeJSON(w http.ResponseWriter, value any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(value)
@@ -0,0 +1,49 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"git.cin.su/m/Assistant/contracts/capability"
)
type coreTaskEvaluationSink struct {
baseURL string
client *http.Client
}
func newCoreTaskEvaluationSink(baseURL string, client *http.Client) (*coreTaskEvaluationSink, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return nil, fmt.Errorf("core runtime URL is required")
}
if client == nil {
client = http.DefaultClient
}
return &coreTaskEvaluationSink{baseURL: baseURL, client: client}, nil
}
func (s *coreTaskEvaluationSink) SaveTaskEvaluation(ctx context.Context, value capability.TaskEvaluation) error {
payload, err := json.Marshal(value)
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL+"/v1/cognitive-registry/task-evaluations", bytes.NewReader(payload))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := s.client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("save task evaluation: status %s", response.Status)
}
return nil
}
+11
View File
@@ -81,6 +81,17 @@ type TaskEvaluationGraphRequest struct {
TriggerEvidenceRef string `json:"trigger_evidence_ref,omitempty"`
}
// TaskEvaluationEvidenceCallback is emitted by a specialist after it has
// completed the bounded case set. Capability remains the evidence owner;
// Work only advances its matching graph node after the evidence is accepted.
type TaskEvaluationEvidenceCallback struct {
GraphID string `json:"graph_id"`
Revision int64 `json:"revision"`
NodeID GraphNodeID `json:"node_id"`
Evaluation capability.TaskEvaluation `json:"evaluation"`
Succeeded bool `json:"succeeded"`
}
type ResultCandidate struct {
ID string `json:"id"`
GraphID string `json:"graph_id"`
+19 -11
View File
@@ -1,6 +1,10 @@
package work
import "time"
import (
"time"
"git.cin.su/m/Assistant/contracts/capability"
)
type AgentLeaseID string
type AgentFencingToken uint64
@@ -50,14 +54,18 @@ type IndependentVerification struct {
}
type AgentDispatchIntent struct {
DispatchID string `json:"dispatch_id"`
IdempotencyKey string `json:"idempotency_key"`
DelegationID DelegationID `json:"delegation_id"`
RunID AgentRunID `json:"run_id"`
ImplementationID string `json:"implementation_id"`
ImplementationRevision int64 `json:"implementation_revision"`
SourceRevision string `json:"source_revision"`
WorktreeID string `json:"worktree_id"`
VerificationPlanRef string `json:"verification_plan_ref"`
CreatedAtUnixMillis int64 `json:"created_at_unix_millis"`
DispatchID string `json:"dispatch_id"`
IdempotencyKey string `json:"idempotency_key"`
DelegationID DelegationID `json:"delegation_id"`
RunID AgentRunID `json:"run_id"`
ImplementationID string `json:"implementation_id"`
ImplementationRevision int64 `json:"implementation_revision"`
SourceRevision string `json:"source_revision"`
WorktreeID string `json:"worktree_id"`
VerificationPlanRef string `json:"verification_plan_ref"`
CreatedAtUnixMillis int64 `json:"created_at_unix_millis"`
SpecialistPurpose string `json:"specialist_purpose,omitempty"`
TaskEvaluationProfileID capability.TaskEvaluationProfileID `json:"task_evaluation_profile_id,omitempty"`
TaskEvaluationBindingID capability.CapabilityBindingID `json:"task_evaluation_binding_id,omitempty"`
TaskEvaluationCaseIDs []string `json:"task_evaluation_case_ids,omitempty"`
}
@@ -0,0 +1,23 @@
BEGIN;
SET ROLE work_role;
ALTER TABLE work.agent_dispatch_intents
ADD COLUMN IF NOT EXISTS specialist_purpose TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS task_evaluation_profile_id TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS task_evaluation_binding_id TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS task_evaluation_case_ids TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE work.graphs
ADD COLUMN IF NOT EXISTS parent_graph_id TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS parent_graph_revision BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS trigger_evidence_ref TEXT NOT NULL DEFAULT '';
ALTER TABLE work.graph_nodes
ADD COLUMN IF NOT EXISTS purpose TEXT NOT NULL DEFAULT 'GENERIC',
ADD COLUMN IF NOT EXISTS task_profile_id TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS binding_id TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS case_ids TEXT[] NOT NULL DEFAULT '{}';
RESET ROLE;
COMMIT;
+2 -2
View File
@@ -1,5 +1,5 @@
{
"task_file": "dev/tasks/DEV-000209-task-evaluator-work-graph.json",
"task_id": "DEV-000209",
"task_file": "dev/tasks/DEV-000211-task-evaluator-callback.json",
"task_id": "DEV-000211",
"task_revision": 1
}
@@ -0,0 +1,23 @@
{
"task_id": "DEV-000210",
"task_revision": 1,
"architecture_revision": "ADR-0018",
"base_repo_revision": "9a6ab6a9caf68e65f302c95ab0859b259122dacf",
"risk_tier": "E",
"intent": "Carry task-evaluation specialist metadata through durable dispatch, persistence and recovery so workers can execute bounded model cases and return evidence without losing profile lineage.",
"requirement_ids": ["worker execution context", "durable task metadata", "recovery-safe dispatch", "bounded case set", "no synchronous chat blocking"],
"allowed_change_scope": {"modules": ["work"], "paths": ["contracts/work/**", "modules/work/**", "adapters/postgres/work_repository.go", "cmd/agent-worker/**", "database/migrations/040_task_evaluation_dispatch.sql", "tests/model_qualification/**", "tests/agent_execution/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000210-task-evaluator-worker.json"]},
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/devctl/**", "modules/identity/**", "modules/capability/**"]},
"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 task-evaluation node metadata", "AgentExecutionRequest/Result", "existing lease and recovery contract"], "may_change": ["typed dispatch task-evaluation metadata"]},
"semantic_owner": "work",
"canonical_state": ["work-owned dispatch metadata and graph lineage; capability remains owner of evaluation evidence"],
"state_transitions": ["graph specialist node -> enriched dispatch intent", "claimed dispatch -> executor receives profile/binding/cases", "stale claim -> recovery preserves task metadata for retry"],
"side_effects_and_privileges": ["work role migration only; no provider calls and no secret access added"],
"persistence_and_migrations": ["040 adds typed task metadata columns to existing work dispatch/graph tables"],
"failure_retry_restart": ["metadata is persisted before enqueue; idempotency conflict rejects divergent task context", "lease timeout/recovery does not erase profile or case references"],
"assumptions": ["an upcoming cognitive specialist executor uses the typed metadata to invoke the model gateway and post Capability evidence", "external bridge absence remains typed WAITING/local-fallback"],
"acceptance_scenarios": ["task graph dispatch carries profile, binding and all cases", "persist/load round trip preserves metadata", "divergent idempotency retry is rejected", "legacy non-task dispatch remains valid", "recovery path does not claim success"],
"required_tests": ["go test", "go vet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "migration/schema checks", "CI", "production public health check"],
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY"]
}
@@ -0,0 +1,23 @@
{
"task_id": "DEV-000211",
"task_revision": 1,
"architecture_revision": "ADR-0018",
"base_repo_revision": "6dec47c79163f456cf6b18085a268ebb2619c7bb",
"risk_tier": "E",
"intent": "Accept specialist task-evaluation evidence through a Work Graph callback: persist evidence through the Capability owner and complete the matching graph node only after typed lineage and revision checks pass.",
"requirement_ids": ["evidence callback", "fan-in completion", "owner-local persistence", "revision safety", "no cross-owner DB access"],
"allowed_change_scope": {"modules": ["work"], "paths": ["contracts/work/**", "modules/work/**", "cmd/work-graph-runtime/**", "tests/model_qualification/**", "tests/agent_execution/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000211-task-evaluator-callback.json"]},
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/devctl/**", "modules/identity/**", "database/**", "modules/capability/**"]},
"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": ["capability.TaskEvaluation", "existing Work Graph revision/completion contract", "Core Runtime capability evaluation HTTP boundary"], "may_change": ["typed task-evaluation callback contract"]},
"semantic_owner": "work for graph lifecycle; capability for evaluation evidence",
"canonical_state": ["Capability owns evaluation evidence; Work owns graph/node lifecycle and callback lineage"],
"state_transitions": ["specialist result -> callback received", "callback -> Capability evidence persisted", "persisted evidence -> matching node completion and graph fan-in"],
"side_effects_and_privileges": ["remote Core Runtime call through existing internal boundary; no direct foreign DB access and no new secrets"],
"persistence_and_migrations": ["uses existing capability task-evaluation and work graph persistence; no migration"],
"failure_retry_restart": ["Capability write failure leaves node incomplete and callback retryable", "stale graph revision is rejected; unknown/unsupported evaluation is not promoted by callback"],
"assumptions": ["specialist executor has already produced evidence and artifact references", "the callback is asynchronous and never blocks the chat request"],
"acceptance_scenarios": ["callback rejects non-task graph node", "callback rejects profile/binding lineage mismatch", "Capability write occurs before node completion", "sink failure leaves graph node unfinished", "valid callback advances graph fan-in", "duplicate evidence remains idempotent at Capability owner"],
"required_tests": ["go test", "go vet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production public health check"],
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY"]
}
+41 -1
View File
@@ -11,6 +11,7 @@ import (
)
var ErrGraphDispatchBinding = errors.New("graph node dispatch binding is missing")
var ErrTaskEvaluationCallbackMismatch = errors.New("task evaluation callback does not match graph node")
type GraphScheduleRequest struct {
Graph work.WorkGraph `json:"graph"`
@@ -42,6 +43,31 @@ type GraphSynthesisRequest struct {
Verified bool `json:"verified"`
}
func CompleteTaskEvaluationNode(ctx context.Context, graphStore ports.GraphStore, evidenceSink ports.TaskEvaluationSink, request work.TaskEvaluationEvidenceCallback) (work.WorkGraph, error) {
if evidenceSink == nil || request.GraphID == "" || request.Revision < 1 || request.NodeID == "" || !request.Succeeded {
return work.WorkGraph{}, ErrTaskEvaluationCallbackMismatch
}
graph, err := graphStore.LoadWorkGraph(ctx, request.GraphID, request.Revision)
if err != nil {
return work.WorkGraph{}, err
}
matched := false
for _, node := range graph.Nodes {
if node.ID != request.NodeID {
continue
}
matched = node.Purpose == work.GraphNodePurposeTaskEvaluation && node.TaskProfileID == request.Evaluation.ProfileID && node.BindingID == request.Evaluation.BindingID
break
}
if !matched || request.Evaluation.BindingRevision < 1 || request.Evaluation.CapabilityID == "" {
return work.WorkGraph{}, ErrTaskEvaluationCallbackMismatch
}
if err := evidenceSink.SaveTaskEvaluation(ctx, request.Evaluation); err != nil {
return work.WorkGraph{}, err
}
return CompleteGraphNode(ctx, graphStore, request.GraphID, request.Revision, request.NodeID, true)
}
func SynthesizeGraph(ctx context.Context, store ports.GraphResultStore, request GraphSynthesisRequest) (work.ConflictSet, work.GraphSynthesisCandidate, error) {
conflict, synthesis, err := domain.BuildVerifiedGraphSynthesis(request.Conflict, request.Candidates, request.SelectedCandidate, request.VerifierRunID, request.VerifierIdentity, request.EvidenceRefs, request.Summary, request.Verified)
if err != nil {
@@ -120,7 +146,21 @@ func AdmitAndScheduleTaskEvaluation(ctx context.Context, store ports.GraphStore,
if err != nil {
return work.WorkGraph{}, nil, err
}
return AdmitAndSchedule(ctx, store, GraphScheduleRequest{Graph: graph, Intents: request.Intents, Now: request.Now})
intents := make([]work.AgentDispatchIntent, len(request.Intents))
copy(intents, request.Intents)
for i := range intents {
for _, node := range graph.Nodes {
if node.DispatchID != intents[i].DispatchID {
continue
}
intents[i].SpecialistPurpose = string(node.Purpose)
intents[i].TaskEvaluationProfileID = node.TaskProfileID
intents[i].TaskEvaluationBindingID = node.BindingID
intents[i].TaskEvaluationCaseIDs = append([]string(nil), node.CaseIDs...)
break
}
}
return AdmitAndSchedule(ctx, store, GraphScheduleRequest{Graph: graph, Intents: intents, Now: request.Now})
}
func CompleteGraphNode(ctx context.Context, store ports.GraphStore, graphID string, revision int64, nodeID work.GraphNodeID, success bool) (work.WorkGraph, error) {
@@ -0,0 +1,80 @@
package application
import (
"context"
"errors"
"testing"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/work"
)
type callbackGraphStore struct {
graph work.WorkGraph
saves int
}
func (s *callbackGraphStore) SaveWorkGraph(_ context.Context, graph work.WorkGraph) error {
s.graph = graph
s.saves++
return nil
}
func (s *callbackGraphStore) LoadWorkGraph(context.Context, string, int64) (work.WorkGraph, error) {
return s.graph, nil
}
func (s *callbackGraphStore) SaveDispatchIntent(context.Context, work.AgentDispatchIntent) error {
return nil
}
func (s *callbackGraphStore) EnqueueDispatch(context.Context, work.AgentDispatchQueueItem) error {
return nil
}
type callbackSink struct {
evaluation capability.TaskEvaluation
err error
}
func (s *callbackSink) SaveTaskEvaluation(_ context.Context, value capability.TaskEvaluation) error {
s.evaluation = value
return s.err
}
func callbackRequest() (work.WorkGraph, work.TaskEvaluationEvidenceCallback) {
graph := work.WorkGraph{ID: "graph-1", Revision: 1, State: work.GraphRunning, Budget: work.GraphBudget{MaxParallel: 1, MaxAttempts: 1}, Nodes: []work.GraphNode{{ID: "node-1", DispatchID: "dispatch-1", IdempotencyKey: "idem-1", State: work.GraphNodeRunning, Purpose: work.GraphNodePurposeTaskEvaluation, TaskProfileID: "profile-1", BindingID: "binding-1"}}}
evaluation := capability.TaskEvaluation{ID: "evaluation-1", ProfileID: "profile-1", BindingID: "binding-1", BindingRevision: 2, CapabilityID: "capability-1", Result: capability.EvaluationSupported}
return graph, work.TaskEvaluationEvidenceCallback{GraphID: "graph-1", Revision: 1, NodeID: "node-1", Evaluation: evaluation, Succeeded: true}
}
func TestCompleteTaskEvaluationPersistsEvidenceBeforeGraphCompletion(t *testing.T) {
graph, callback := callbackRequest()
store := &callbackGraphStore{graph: graph}
sink := &callbackSink{}
completed, err := CompleteTaskEvaluationNode(context.Background(), store, sink, callback)
if err != nil {
t.Fatal(err)
}
if sink.evaluation.ID != "evaluation-1" || store.saves != 1 || completed.State != work.GraphCompleted || completed.Nodes[0].State != work.GraphNodeSuccess {
t.Fatalf("callback did not complete in order: evaluation=%+v saves=%d graph=%+v", sink.evaluation, store.saves, completed)
}
}
func TestCompleteTaskEvaluationLeavesNodeOpenWhenCapabilityRejectsEvidence(t *testing.T) {
graph, callback := callbackRequest()
store := &callbackGraphStore{graph: graph}
sink := &callbackSink{err: errors.New("capability unavailable")}
if _, err := CompleteTaskEvaluationNode(context.Background(), store, sink, callback); err == nil {
t.Fatal("expected capability failure")
}
if store.saves != 0 {
t.Fatalf("graph was advanced after capability failure: saves=%d", store.saves)
}
}
func TestCompleteTaskEvaluationRejectsLineageMismatch(t *testing.T) {
graph, callback := callbackRequest()
callback.Evaluation.ProfileID = "profile-other"
store := &callbackGraphStore{graph: graph}
if _, err := CompleteTaskEvaluationNode(context.Background(), store, &callbackSink{}, callback); !errors.Is(err, ErrTaskEvaluationCallbackMismatch) {
t.Fatalf("expected lineage rejection, got %v", err)
}
}
@@ -0,0 +1,46 @@
package application
import (
"context"
"testing"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/work"
)
type taskGraphStore struct {
graph work.WorkGraph
intents []work.AgentDispatchIntent
}
func (s *taskGraphStore) SaveWorkGraph(_ context.Context, graph work.WorkGraph) error {
s.graph = graph
return nil
}
func (s *taskGraphStore) LoadWorkGraph(context.Context, string, int64) (work.WorkGraph, error) {
return s.graph, nil
}
func (s *taskGraphStore) SaveDispatchIntent(_ context.Context, intent work.AgentDispatchIntent) error {
s.intents = append(s.intents, intent)
return nil
}
func (s *taskGraphStore) EnqueueDispatch(context.Context, work.AgentDispatchQueueItem) error {
return nil
}
func TestAdmitAndScheduleTaskEvaluationCarriesSpecialistContext(t *testing.T) {
store := &taskGraphStore{}
request := TaskEvaluationScheduleRequest{
Plan: work.TaskEvaluationGraphRequest{
GraphID: "graph-task", Profile: capability.TaskEvaluationProfile{ID: "profile-general", Revision: 1, CapabilityID: "capability-chat", Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "general-v1", RequiredCaseIDs: []string{"case-a", "case-b"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100},
BindingIDs: []capability.CapabilityBindingID{"binding-a"}, DispatchIDs: []string{"dispatch-a"}, IdempotencyPrefix: "eval", Budget: work.GraphBudget{MaxParallel: 1, MaxAttempts: 2},
},
Intents: []work.AgentDispatchIntent{{DispatchID: "dispatch-a", IdempotencyKey: "idem-a", DelegationID: "delegation-a", RunID: "run-a", ImplementationID: "impl", ImplementationRevision: 1, SourceRevision: "source", WorktreeID: "tree", VerificationPlanRef: "verify", CreatedAtUnixMillis: 1}},
}
if _, _, err := AdmitAndScheduleTaskEvaluation(context.Background(), store, request); err != nil {
t.Fatal(err)
}
if len(store.intents) != 1 || store.intents[0].TaskEvaluationProfileID != "profile-general" || store.intents[0].TaskEvaluationBindingID != "binding-a" || len(store.intents[0].TaskEvaluationCaseIDs) != 2 || store.intents[0].SpecialistPurpose != string(work.GraphNodePurposeTaskEvaluation) {
t.Fatalf("specialist context was not carried into dispatch: %+v", store.intents)
}
}
+7
View File
@@ -3,6 +3,7 @@ package ports
import (
"context"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/work"
)
@@ -16,3 +17,9 @@ type GraphStore interface {
type GraphResultStore interface {
SaveGraphSynthesis(context.Context, work.ConflictSet, []work.ResultCandidate, work.GraphSynthesisCandidate) error
}
// TaskEvaluationSink is a remote-capable boundary to Capability's owner.
// Work never writes the capability schema directly.
type TaskEvaluationSink interface {
SaveTaskEvaluation(context.Context, capability.TaskEvaluation) error
}
@@ -0,0 +1,20 @@
package agent_execution
import (
"os"
"strings"
"testing"
)
func TestTaskEvaluationDispatchMigrationPersistsWorkerContext(t *testing.T) {
payload, err := os.ReadFile("../../database/migrations/040_task_evaluation_dispatch.sql")
if err != nil {
t.Fatal(err)
}
sql := strings.ToLower(string(payload))
for _, marker := range []string{"work.agent_dispatch_intents", "specialist_purpose", "task_evaluation_profile_id", "task_evaluation_binding_id", "task_evaluation_case_ids", "work.graphs", "parent_graph_id", "work.graph_nodes", "case_ids", "set role work_role"} {
if !strings.Contains(sql, strings.ToLower(marker)) {
t.Fatalf("migration missing %q", marker)
}
}
}