Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54958d0902 | ||
|
|
8d3e7f7cac | ||
|
|
c9eb398857 | ||
|
|
284ea88553 | ||
|
|
f9b9765100 | ||
|
|
863a379048 | ||
|
|
a86d1994c3 |
@@ -2,11 +2,14 @@ package postgres
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/capability"
|
||||||
"git.cin.su/m/Assistant/contracts/work"
|
"git.cin.su/m/Assistant/contracts/work"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -15,10 +18,11 @@ import (
|
|||||||
type WorkRepository struct{ pool *pgxpool.Pool }
|
type WorkRepository struct{ pool *pgxpool.Pool }
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrDispatchIntentConflict = errors.New("dispatch intent idempotency conflict")
|
ErrDispatchIntentConflict = errors.New("dispatch intent idempotency conflict")
|
||||||
ErrAgentLeaseUnavailable = errors.New("agent lease unavailable")
|
ErrAgentLeaseUnavailable = errors.New("agent lease unavailable")
|
||||||
ErrAgentCallbackRejected = errors.New("agent callback rejected")
|
ErrAgentCallbackRejected = errors.New("agent callback rejected")
|
||||||
ErrDevelopmentGateConflict = errors.New("development gate revision conflict")
|
ErrDevelopmentGateConflict = errors.New("development gate revision conflict")
|
||||||
|
ErrDevelopmentCandidateConflict = errors.New("development candidate conflict")
|
||||||
)
|
)
|
||||||
|
|
||||||
func OpenWork(ctx context.Context, config ContinuityConfig) (*WorkRepository, error) {
|
func OpenWork(ctx context.Context, config ContinuityConfig) (*WorkRepository, error) {
|
||||||
@@ -101,21 +105,60 @@ func (r *WorkRepository) SaveDevelopmentGate(ctx context.Context, gate work.Deve
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *WorkRepository) SaveExtensionProposal(ctx context.Context, value work.ExtensionProposal) error {
|
||||||
|
result, err := r.pool.Exec(ctx, `INSERT INTO work.extension_proposals(proposal_id,requested_by_principal_id,stage,evidence_refs,deduplicated_signal_keys,desired_outcome,smallest_change_layer,affected_owners,affected_contracts,risk_tier,evaluation_plan,alternatives,rollback_plan,assumptions,created_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT(proposal_id) DO NOTHING`, value.ID, value.RequestedByPrincipalID, value.Stage, value.EvidenceRefs, value.DeduplicatedSignalKeys, value.DesiredOutcome, value.SmallestChangeLayer, value.AffectedOwners, value.AffectedContracts, value.RiskTier, value.EvaluationPlan, value.Alternatives, value.RollbackPlan, value.Assumptions, value.CreatedAtUnixMillis)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.RowsAffected() != 1 {
|
||||||
|
return ErrDevelopmentCandidateConflict
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkRepository) SaveDevelopmentScaffold(ctx context.Context, value work.DevelopmentScaffold) error {
|
||||||
|
result, err := r.pool.Exec(ctx, `INSERT INTO work.development_scaffolds(scaffold_id,requested_by_principal_id,stage,proposal_ref,base_repo_revision,worktree_ref,task_intent,modules,allowed_paths,forbidden_paths,required_tests,artifact_lineage_refs,protected_main_access,production_access,secret_access,created_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) ON CONFLICT(scaffold_id) DO NOTHING`, value.ID, value.RequestedByPrincipalID, value.Stage, value.ProposalRef, value.BaseRepoRevision, value.WorktreeRef, value.TaskIntent, value.Modules, value.AllowedPaths, value.ForbiddenPaths, value.RequiredTests, value.ArtifactLineageRefs, value.ProtectedMainAccess, value.ProductionAccess, value.SecretAccess, value.CreatedAtUnixMillis)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.RowsAffected() != 1 {
|
||||||
|
return ErrDevelopmentCandidateConflict
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *WorkRepository) SaveDispatchIntent(ctx context.Context, intent work.AgentDispatchIntent) error {
|
func (r *WorkRepository) SaveDispatchIntent(ctx context.Context, intent work.AgentDispatchIntent) error {
|
||||||
tx, err := r.pool.Begin(ctx)
|
tx, err := r.pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
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,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)
|
profileJSON, casesJSON, err := marshalTaskPayload(intent.TaskEvaluationProfile, intent.TaskEvaluationCases)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
executionJSON, err := json.Marshal(intent.TaskEvaluationExecution)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, 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,task_evaluation_profile,task_evaluation_cases,task_evaluation_execution) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) 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, profileJSON, casesJSON, executionJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("save dispatch intent: %w", err)
|
return fmt.Errorf("save dispatch intent: %w", err)
|
||||||
}
|
}
|
||||||
var existing work.AgentDispatchIntent
|
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,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)
|
var profileJSONExisting, casesJSONExisting, executionJSONExisting []byte
|
||||||
|
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,task_evaluation_profile,task_evaluation_cases,task_evaluation_execution 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, &profileJSONExisting, &casesJSONExisting, &executionJSONExisting)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read dispatch intent: %w", err)
|
return fmt.Errorf("read dispatch intent: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := unmarshalTaskPayload(profileJSONExisting, casesJSONExisting, &existing.TaskEvaluationProfile, &existing.TaskEvaluationCases); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(executionJSONExisting) > 0 && string(executionJSONExisting) != "null" {
|
||||||
|
if err := json.Unmarshal(executionJSONExisting, &existing.TaskEvaluationExecution); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if !sameDispatchIntent(existing, intent) {
|
if !sameDispatchIntent(existing, intent) {
|
||||||
return ErrDispatchIntentConflict
|
return ErrDispatchIntentConflict
|
||||||
}
|
}
|
||||||
@@ -123,15 +166,24 @@ func (r *WorkRepository) SaveDispatchIntent(ctx context.Context, intent work.Age
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sameDispatchIntent(left, right work.AgentDispatchIntent) bool {
|
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)
|
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) && reflect.DeepEqual(left.TaskEvaluationProfile, right.TaskEvaluationProfile) && slices.Equal(left.TaskEvaluationCases, right.TaskEvaluationCases) && reflect.DeepEqual(left.TaskEvaluationExecution, right.TaskEvaluationExecution)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *WorkRepository) LoadDispatchIntent(ctx context.Context, dispatchID string) (work.AgentDispatchIntent, error) {
|
func (r *WorkRepository) LoadDispatchIntent(ctx context.Context, dispatchID string) (work.AgentDispatchIntent, error) {
|
||||||
var intent work.AgentDispatchIntent
|
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,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)
|
var profileJSON, casesJSON, executionJSON []byte
|
||||||
|
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,task_evaluation_profile,task_evaluation_cases,task_evaluation_execution 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, &profileJSON, &casesJSON, &executionJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return work.AgentDispatchIntent{}, fmt.Errorf("load dispatch intent: %w", err)
|
return work.AgentDispatchIntent{}, fmt.Errorf("load dispatch intent: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := unmarshalTaskPayload(profileJSON, casesJSON, &intent.TaskEvaluationProfile, &intent.TaskEvaluationCases); err != nil {
|
||||||
|
return work.AgentDispatchIntent{}, err
|
||||||
|
}
|
||||||
|
if len(executionJSON) > 0 && string(executionJSON) != "null" {
|
||||||
|
if err := json.Unmarshal(executionJSON, &intent.TaskEvaluationExecution); err != nil {
|
||||||
|
return work.AgentDispatchIntent{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
return intent, nil
|
return intent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +199,7 @@ func (r *WorkRepository) ClaimNextDispatch(ctx context.Context, workerID string,
|
|||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
var item work.AgentDispatchQueueItem
|
var item work.AgentDispatchQueueItem
|
||||||
err = tx.QueryRow(ctx, `SELECT dispatch_id,state,available_at_unix_millis,attempts,claimed_by,updated_at_unix_millis FROM work.agent_dispatch_queue WHERE state='QUEUED' AND available_at_unix_millis <= $1 ORDER BY available_at_unix_millis,dispatch_id FOR UPDATE SKIP LOCKED LIMIT 1`, nowUnixMillis).Scan(&item.DispatchID, &item.State, &item.AvailableAtUnixMillis, &item.Attempts, &item.ClaimedBy, &item.UpdatedAtUnixMillis)
|
err = tx.QueryRow(ctx, `SELECT dispatch_id,state,available_at_unix_millis,attempts,claimed_by,updated_at_unix_millis FROM work.agent_dispatch_queue WHERE state='QUEUED' AND available_at_unix_millis <= $1 AND attempts < retry_limit ORDER BY available_at_unix_millis,dispatch_id FOR UPDATE SKIP LOCKED LIMIT 1`, nowUnixMillis).Scan(&item.DispatchID, &item.State, &item.AvailableAtUnixMillis, &item.Attempts, &item.ClaimedBy, &item.UpdatedAtUnixMillis)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return work.AgentDispatchQueueItem{}, nil
|
return work.AgentDispatchQueueItem{}, nil
|
||||||
@@ -190,6 +242,21 @@ func (r *WorkRepository) CompleteDispatch(ctx context.Context, dispatchID, worke
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *WorkRepository) RetryDispatch(ctx context.Context, dispatchID, workerID string, nowUnixMillis int64, delay time.Duration) error {
|
||||||
|
if delay < 0 {
|
||||||
|
return fmt.Errorf("invalid retry delay")
|
||||||
|
}
|
||||||
|
availableAt := nowUnixMillis + delay.Milliseconds()
|
||||||
|
command, err := r.pool.Exec(ctx, `UPDATE work.agent_dispatch_queue SET state=CASE WHEN attempts >= retry_limit THEN 'FAILED' ELSE 'QUEUED' END,available_at_unix_millis=$1,claimed_by='',updated_at_unix_millis=$2 WHERE dispatch_id=$3 AND state='CLAIMED' AND claimed_by=$4`, availableAt, nowUnixMillis, dispatchID, workerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if command.RowsAffected() != 1 {
|
||||||
|
return ErrAgentCallbackRejected
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *WorkRepository) SaveWorkGraph(ctx context.Context, graph work.WorkGraph) error {
|
func (r *WorkRepository) SaveWorkGraph(ctx context.Context, graph work.WorkGraph) error {
|
||||||
tx, err := r.pool.Begin(ctx)
|
tx, err := r.pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -200,7 +267,11 @@ func (r *WorkRepository) SaveWorkGraph(ctx context.Context, graph work.WorkGraph
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, node := range graph.Nodes {
|
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,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 {
|
profileJSON, casesJSON, err := marshalTaskPayload(node.TaskProfile, node.TaskCases)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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,task_evaluation_profile,task_evaluation_cases) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) 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, profileJSON, casesJSON); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,13 +289,18 @@ func (r *WorkRepository) LoadWorkGraph(ctx context.Context, graphID string, revi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return work.WorkGraph{}, fmt.Errorf("load graph: %w", err)
|
return work.WorkGraph{}, fmt.Errorf("load graph: %w", err)
|
||||||
}
|
}
|
||||||
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)
|
rows, err := r.pool.Query(ctx, `SELECT node_id,dispatch_id,idempotency_key,state,attempts,purpose,task_profile_id,binding_id,case_ids,task_evaluation_profile,task_evaluation_cases FROM work.graph_nodes WHERE graph_id=$1 AND revision=$2 ORDER BY node_id`, graphID, revision)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return work.WorkGraph{}, err
|
return work.WorkGraph{}, err
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var node work.GraphNode
|
var node work.GraphNode
|
||||||
if err := rows.Scan(&node.ID, &node.DispatchID, &node.IdempotencyKey, &node.State, &node.Attempts, &node.Purpose, &node.TaskProfileID, &node.BindingID, &node.CaseIDs); err != nil {
|
var profileJSON, casesJSON []byte
|
||||||
|
if err := rows.Scan(&node.ID, &node.DispatchID, &node.IdempotencyKey, &node.State, &node.Attempts, &node.Purpose, &node.TaskProfileID, &node.BindingID, &node.CaseIDs, &profileJSON, &casesJSON); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return work.WorkGraph{}, err
|
||||||
|
}
|
||||||
|
if err := unmarshalTaskPayload(profileJSON, casesJSON, &node.TaskProfile, &node.TaskCases); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
return work.WorkGraph{}, err
|
return work.WorkGraph{}, err
|
||||||
}
|
}
|
||||||
@@ -276,10 +352,21 @@ func (r *WorkRepository) SaveGraphSynthesis(ctx context.Context, conflict work.C
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *WorkRepository) RecoverStaleClaims(ctx context.Context, nowUnixMillis, staleAfterMillis int64) (int64, error) {
|
func (r *WorkRepository) RecoverStaleClaims(ctx context.Context, nowUnixMillis, staleAfterMillis int64) (int64, error) {
|
||||||
command, err := r.pool.Exec(ctx, `UPDATE work.agent_dispatch_queue SET state='QUEUED',claimed_by='',updated_at_unix_millis=$1 WHERE state='CLAIMED' AND updated_at_unix_millis <= $2`, nowUnixMillis, nowUnixMillis-staleAfterMillis)
|
tx, err := r.pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE work.agent_leases AS l SET state='EXPIRED' FROM work.agent_dispatch_queue AS q JOIN work.agent_dispatch_intents AS i ON i.dispatch_id=q.dispatch_id WHERE q.state='CLAIMED' AND q.updated_at_unix_millis <= $1 AND l.run_id=i.run_id AND l.state='ACTIVE'`, nowUnixMillis-staleAfterMillis); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
command, err := tx.Exec(ctx, `UPDATE work.agent_dispatch_queue SET state=CASE WHEN attempts >= retry_limit THEN 'FAILED' ELSE 'QUEUED' END,available_at_unix_millis=$1,claimed_by='',updated_at_unix_millis=$2 WHERE state='CLAIMED' AND updated_at_unix_millis <= $3`, nowUnixMillis, nowUnixMillis, nowUnixMillis-staleAfterMillis)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
return command.RowsAffected(), nil
|
return command.RowsAffected(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,3 +657,32 @@ func latestRowErr(err error) error {
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func marshalTaskPayload(profile *capability.TaskEvaluationProfile, cases []capability.TaskEvaluationCase) ([]byte, []byte, error) {
|
||||||
|
var profileJSON []byte
|
||||||
|
var err error
|
||||||
|
if profile != nil {
|
||||||
|
profileJSON, err = json.Marshal(profile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
casesJSON, err := json.Marshal(cases)
|
||||||
|
return profileJSON, casesJSON, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmarshalTaskPayload(profileJSON, casesJSON []byte, profile **capability.TaskEvaluationProfile, cases *[]capability.TaskEvaluationCase) error {
|
||||||
|
if len(profileJSON) > 0 && string(profileJSON) != "null" {
|
||||||
|
var value capability.TaskEvaluationProfile
|
||||||
|
if err := json.Unmarshal(profileJSON, &value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*profile = &value
|
||||||
|
}
|
||||||
|
if len(casesJSON) > 0 && string(casesJSON) != "null" {
|
||||||
|
if err := json.Unmarshal(casesJSON, cases); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ func (s *memoryStore) SaveDispatchIntent(context.Context, work.AgentDispatchInte
|
|||||||
s.writes++
|
s.writes++
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (s *memoryStore) SaveAgentLease(context.Context, work.AgentLease) error { s.writes++; return nil }
|
func (s *memoryStore) SaveAgentLease(context.Context, work.AgentLease) error { s.writes++; return nil }
|
||||||
|
func (s *memoryStore) RenewAgentLease(context.Context, work.AgentLease, time.Time) error { return nil }
|
||||||
func (s *memoryStore) AcceptAgentCallback(context.Context, work.AgentCallback) error {
|
func (s *memoryStore) AcceptAgentCallback(context.Context, work.AgentCallback) error {
|
||||||
s.writes++
|
s.writes++
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -49,9 +49,8 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
executor = newLocalFallbackExecutor()
|
executor = newTaskEvaluationExecutor(envOr("COGNITIVE_WORKER_URL", "http://cognitive-worker:8083"), envOr("WORK_GRAPH_RUNTIME_URL", "http://work-graph-runtime:8088"), &http.Client{Timeout: *leaseTTL})
|
||||||
localFallback = true
|
fmt.Fprintln(os.Stderr, "protected agent execution bridge URL is not configured; task evaluations use the local durable evaluator, other work remains in fallback")
|
||||||
fmt.Fprintln(os.Stderr, "protected agent execution bridge URL is not configured; local fallback preserves durable work without external mutation")
|
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
if serveErr := http.ListenAndServe(*statusAddr, workerStatusHandler(executor != nil && !localFallback, localFallback)); serveErr != nil {
|
if serveErr := http.ListenAndServe(*statusAddr, workerStatusHandler(executor != nil && !localFallback, localFallback)); serveErr != nil {
|
||||||
@@ -107,6 +106,13 @@ func loadProtectedBridgeURLFrom(root, path string) (string, bool) {
|
|||||||
return value, true
|
return value, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func envOr(name, fallback string) string {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
func resolveDatabasePassword(name, refName string, consumer secretcontracts.RuntimeConsumer) (string, error) {
|
func resolveDatabasePassword(name, refName string, consumer secretcontracts.RuntimeConsumer) (string, error) {
|
||||||
if _, ok := os.LookupEnv(name); ok {
|
if _, ok := os.LookupEnv(name); ok {
|
||||||
return "", fmt.Errorf("%s direct environment secret is forbidden", name)
|
return "", fmt.Errorf("%s direct environment secret is forbidden", name)
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/artifact"
|
||||||
|
"git.cin.su/m/Assistant/contracts/capability"
|
||||||
|
"git.cin.su/m/Assistant/contracts/work"
|
||||||
|
capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type taskEvaluationExecutor struct {
|
||||||
|
cognitiveURL string
|
||||||
|
callbackURL string
|
||||||
|
client *http.Client
|
||||||
|
fallback localFallbackExecutor
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTaskEvaluationExecutor(cognitiveURL, callbackURL string, client *http.Client) taskEvaluationExecutor {
|
||||||
|
if client == nil {
|
||||||
|
client = http.DefaultClient
|
||||||
|
}
|
||||||
|
return taskEvaluationExecutor{cognitiveURL: strings.TrimRight(cognitiveURL, "/"), callbackURL: strings.TrimRight(callbackURL, "/"), client: client, fallback: newLocalFallbackExecutor()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e taskEvaluationExecutor) Execute(ctx context.Context, request work.AgentExecutionRequest) (work.AgentExecutionResult, error) {
|
||||||
|
intent := request.Intent
|
||||||
|
if intent.TaskEvaluationExecution == nil || intent.TaskEvaluationProfile == nil || len(intent.TaskEvaluationCases) == 0 {
|
||||||
|
return e.fallback.Execute(ctx, request)
|
||||||
|
}
|
||||||
|
execution := intent.TaskEvaluationExecution
|
||||||
|
if execution.GraphID == "" || execution.NodeID == "" || execution.BindingRevision < 1 || execution.ActorID == "" || execution.PrincipalID == "" || execution.ScopeID == "" {
|
||||||
|
return e.fallback.Execute(ctx, request)
|
||||||
|
}
|
||||||
|
if e.cognitiveURL == "" || e.callbackURL == "" {
|
||||||
|
return e.fallback.Execute(ctx, request)
|
||||||
|
}
|
||||||
|
payload := capability.TaskEvaluationExecutionRequest{Profile: *intent.TaskEvaluationProfile, CapabilityID: intent.TaskEvaluationProfile.CapabilityID, BindingID: intent.TaskEvaluationBindingID, BindingRevision: execution.BindingRevision, ActorID: execution.ActorID, PrincipalID: execution.PrincipalID, ScopeID: execution.ScopeID, Cases: intent.TaskEvaluationCases, MaxOutputBytes: 4096}
|
||||||
|
response, err := postJSON[capability.TaskEvaluationExecutionResponse](ctx, e.client, e.cognitiveURL+"/v1/task-evaluations/execute", payload)
|
||||||
|
if err != nil {
|
||||||
|
return work.AgentExecutionResult{}, err
|
||||||
|
}
|
||||||
|
if len(response.Observations) == 0 || response.ObservedAtUnixMillis < 1 {
|
||||||
|
return work.AgentExecutionResult{}, fmt.Errorf("task evaluation returned no observations")
|
||||||
|
}
|
||||||
|
if len(intent.TaskEvaluationCases) == 0 {
|
||||||
|
return work.AgentExecutionResult{}, fmt.Errorf("task evaluation cases are missing")
|
||||||
|
}
|
||||||
|
evaluation, err := capabilitydomain.BuildTaskEvaluation(*intent.TaskEvaluationProfile, capability.TaskEvaluationID("evaluation:"+intent.DispatchID), response.BindingID, response.BindingRevision, intent.TaskEvaluationProfile.CapabilityID, response.Observations, artifact.ArtifactID(intent.TaskEvaluationCases[0].EvidenceArtifactRef), response.ObservedAtUnixMillis)
|
||||||
|
if err != nil {
|
||||||
|
return work.AgentExecutionResult{}, err
|
||||||
|
}
|
||||||
|
callback := work.TaskEvaluationEvidenceCallback{GraphID: execution.GraphID, Revision: execution.GraphRevision, NodeID: execution.NodeID, Evaluation: evaluation, Succeeded: true}
|
||||||
|
if _, err := postJSON[json.RawMessage](ctx, e.client, e.callbackURL+"/v1/work-graphs/task-evaluations/callback", callback); err != nil {
|
||||||
|
return work.AgentExecutionResult{}, err
|
||||||
|
}
|
||||||
|
return work.AgentExecutionResult{State: work.AgentExecutionCompleted, Summary: "task evaluation executed, aggregated and accepted by Work Graph callback", OutcomeRef: string(evaluation.ID), ArtifactRefs: []string{string(evaluation.ArtifactID)}, ObservedAtUnixMillis: time.Now().UnixMilli()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func postJSON[T any](ctx context.Context, client *http.Client, url string, payload any) (T, error) {
|
||||||
|
var result T
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return result, fmt.Errorf("POST %s: status %s", url, resp.Status)
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusNoContent {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/capability"
|
||||||
|
"git.cin.su/m/Assistant/contracts/work"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTaskEvaluationExecutorInvokesAggregatesAndCallbacks(t *testing.T) {
|
||||||
|
profile := capability.TaskEvaluationProfile{ID: "profile-1", Revision: 1, CapabilityID: "capability-1", Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "general-v1", RequiredCaseIDs: []string{"case-1"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/v1/task-evaluations/execute" {
|
||||||
|
_ = json.NewEncoder(w).Encode(capability.TaskEvaluationExecutionResponse{ProfileID: profile.ID, BindingID: "binding-1", BindingRevision: 3, Observations: []capability.TaskCaseObservation{{CaseID: "case-1", Result: capability.EvaluationSupported, EvidenceArtifactRef: "artifact-1"}}, ObservedAtUnixMillis: 10})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/v1/work-graphs/task-evaluations/callback" {
|
||||||
|
var callback work.TaskEvaluationEvidenceCallback
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil || callback.GraphID != "graph-1" || callback.NodeID != "node-1" || callback.Evaluation.Result != capability.EvaluationSupported {
|
||||||
|
http.Error(w, "invalid callback", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
executor := newTaskEvaluationExecutor(server.URL, server.URL, server.Client())
|
||||||
|
result, err := executor.Execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{DispatchID: "dispatch-1", TaskEvaluationBindingID: "binding-1", TaskEvaluationProfile: &profile, TaskEvaluationCases: []capability.TaskEvaluationCase{{CaseID: "case-1", Prompt: "answer", EvidenceArtifactRef: "artifact-1"}}, TaskEvaluationExecution: &work.TaskEvaluationDispatchContext{GraphID: "graph-1", GraphRevision: 1, NodeID: "node-1", BindingRevision: 3, ActorID: "actor-1", PrincipalID: "principal-1", ScopeID: "scope-1"}}})
|
||||||
|
if err != nil || result.State != work.AgentExecutionCompleted || result.OutcomeRef == "" {
|
||||||
|
t.Fatalf("result=%+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -178,6 +178,61 @@ func main() {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(session)
|
_ = json.NewEncoder(w).Encode(session)
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("POST /v1/creator/development/proposals", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, ok := authenticateCreator(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request struct {
|
||||||
|
Evidence []work.ExtensionEvidence `json:"evidence"`
|
||||||
|
Proposal work.ExtensionProposalRequest `json:"proposal"`
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&request); err != nil {
|
||||||
|
http.Error(w, "invalid development proposal", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proposal, err := workdomain.BuildExtensionProposal(request.Evidence, request.Proposal)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "development proposal rejected", http.StatusUnprocessableEntity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proposal.RequestedByPrincipalID = string(session.PrincipalID)
|
||||||
|
if err := workRepository.SaveExtensionProposal(r.Context(), proposal); err != nil {
|
||||||
|
http.Error(w, "development proposal conflict", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(proposal)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /v1/creator/development/scaffolds", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, ok := authenticateCreator(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request work.DevelopmentScaffoldRequest
|
||||||
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&request); err != nil {
|
||||||
|
http.Error(w, "invalid development scaffold", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scaffold, err := workdomain.BuildDevelopmentScaffold(request)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "development scaffold rejected", http.StatusUnprocessableEntity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scaffold.RequestedByPrincipalID = string(session.PrincipalID)
|
||||||
|
if err := workRepository.SaveDevelopmentScaffold(r.Context(), scaffold); err != nil {
|
||||||
|
http.Error(w, "development scaffold conflict", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(scaffold)
|
||||||
|
})
|
||||||
mux.HandleFunc("GET /v1/creator/focus", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /v1/creator/focus", func(w http.ResponseWriter, r *http.Request) {
|
||||||
session, ok := authenticateCreator(w, r)
|
session, ok := authenticateCreator(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ type GraphNode struct {
|
|||||||
TaskProfileID capability.TaskEvaluationProfileID `json:"task_profile_id,omitempty"`
|
TaskProfileID capability.TaskEvaluationProfileID `json:"task_profile_id,omitempty"`
|
||||||
BindingID capability.CapabilityBindingID `json:"binding_id,omitempty"`
|
BindingID capability.CapabilityBindingID `json:"binding_id,omitempty"`
|
||||||
CaseIDs []string `json:"case_ids,omitempty"`
|
CaseIDs []string `json:"case_ids,omitempty"`
|
||||||
|
TaskProfile *capability.TaskEvaluationProfile `json:"task_profile,omitempty"`
|
||||||
|
TaskCases []capability.TaskEvaluationCase `json:"task_cases,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GraphEdge struct {
|
type GraphEdge struct {
|
||||||
@@ -79,6 +81,11 @@ type TaskEvaluationGraphRequest struct {
|
|||||||
ParentGraphRevision int64 `json:"parent_graph_revision,omitempty"`
|
ParentGraphRevision int64 `json:"parent_graph_revision,omitempty"`
|
||||||
ParentGraphState GraphState `json:"parent_graph_state,omitempty"`
|
ParentGraphState GraphState `json:"parent_graph_state,omitempty"`
|
||||||
TriggerEvidenceRef string `json:"trigger_evidence_ref,omitempty"`
|
TriggerEvidenceRef string `json:"trigger_evidence_ref,omitempty"`
|
||||||
|
Cases []capability.TaskEvaluationCase `json:"cases,omitempty"`
|
||||||
|
BindingRevision int64 `json:"binding_revision,omitempty"`
|
||||||
|
ActorID string `json:"actor_id,omitempty"`
|
||||||
|
PrincipalID string `json:"principal_id,omitempty"`
|
||||||
|
ScopeID string `json:"scope_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskEvaluationEvidenceCallback is emitted by a specialist after it has
|
// TaskEvaluationEvidenceCallback is emitted by a specialist after it has
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package work
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/authority"
|
||||||
"git.cin.su/m/Assistant/contracts/capability"
|
"git.cin.su/m/Assistant/contracts/capability"
|
||||||
|
"git.cin.su/m/Assistant/contracts/identity"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AgentLeaseID string
|
type AgentLeaseID string
|
||||||
@@ -68,4 +70,17 @@ type AgentDispatchIntent struct {
|
|||||||
TaskEvaluationProfileID capability.TaskEvaluationProfileID `json:"task_evaluation_profile_id,omitempty"`
|
TaskEvaluationProfileID capability.TaskEvaluationProfileID `json:"task_evaluation_profile_id,omitempty"`
|
||||||
TaskEvaluationBindingID capability.CapabilityBindingID `json:"task_evaluation_binding_id,omitempty"`
|
TaskEvaluationBindingID capability.CapabilityBindingID `json:"task_evaluation_binding_id,omitempty"`
|
||||||
TaskEvaluationCaseIDs []string `json:"task_evaluation_case_ids,omitempty"`
|
TaskEvaluationCaseIDs []string `json:"task_evaluation_case_ids,omitempty"`
|
||||||
|
TaskEvaluationProfile *capability.TaskEvaluationProfile `json:"task_evaluation_profile,omitempty"`
|
||||||
|
TaskEvaluationCases []capability.TaskEvaluationCase `json:"task_evaluation_cases,omitempty"`
|
||||||
|
TaskEvaluationExecution *TaskEvaluationDispatchContext `json:"task_evaluation_execution,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskEvaluationDispatchContext struct {
|
||||||
|
GraphID string `json:"graph_id"`
|
||||||
|
GraphRevision int64 `json:"graph_revision"`
|
||||||
|
NodeID GraphNodeID `json:"node_id"`
|
||||||
|
BindingRevision int64 `json:"binding_revision"`
|
||||||
|
ActorID identity.ActorID `json:"actor_id"`
|
||||||
|
PrincipalID identity.PrincipalID `json:"principal_id"`
|
||||||
|
ScopeID authority.ScopeID `json:"scope_id"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ type ExtensionProposalRequest struct {
|
|||||||
// authority grant, implementation body or promotion decision.
|
// authority grant, implementation body or promotion decision.
|
||||||
type ExtensionProposal struct {
|
type ExtensionProposal struct {
|
||||||
ID string
|
ID string
|
||||||
|
RequestedByPrincipalID string
|
||||||
Stage ExtensionProposalStage
|
Stage ExtensionProposalStage
|
||||||
EvidenceRefs []string
|
EvidenceRefs []string
|
||||||
DeduplicatedSignalKeys []string
|
DeduplicatedSignalKeys []string
|
||||||
@@ -113,19 +114,20 @@ type DevelopmentScaffoldRequest struct {
|
|||||||
// DevelopmentScaffold is a provenance-linked task-packet candidate. It has
|
// DevelopmentScaffold is a provenance-linked task-packet candidate. It has
|
||||||
// no executable instruction, credential, checkout handle or authority grant.
|
// no executable instruction, credential, checkout handle or authority grant.
|
||||||
type DevelopmentScaffold struct {
|
type DevelopmentScaffold struct {
|
||||||
ID string
|
ID string
|
||||||
Stage DevelopmentScaffoldStage
|
RequestedByPrincipalID string
|
||||||
ProposalRef string
|
Stage DevelopmentScaffoldStage
|
||||||
BaseRepoRevision string
|
ProposalRef string
|
||||||
WorktreeRef string
|
BaseRepoRevision string
|
||||||
TaskIntent string
|
WorktreeRef string
|
||||||
Modules []string
|
TaskIntent string
|
||||||
AllowedPaths []string
|
Modules []string
|
||||||
ForbiddenPaths []string
|
AllowedPaths []string
|
||||||
RequiredTests []string
|
ForbiddenPaths []string
|
||||||
ArtifactLineageRefs []string
|
RequiredTests []string
|
||||||
ProtectedMainAccess string
|
ArtifactLineageRefs []string
|
||||||
ProductionAccess string
|
ProtectedMainAccess string
|
||||||
SecretAccess string
|
ProductionAccess string
|
||||||
CreatedAtUnixMillis int64
|
SecretAccess string
|
||||||
|
CreatedAtUnixMillis int64
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SET ROLE work_role;
|
||||||
|
|
||||||
|
ALTER TABLE work.agent_dispatch_intents
|
||||||
|
ADD COLUMN IF NOT EXISTS task_evaluation_profile JSONB,
|
||||||
|
ADD COLUMN IF NOT EXISTS task_evaluation_cases JSONB NOT NULL DEFAULT '[]',
|
||||||
|
ADD COLUMN IF NOT EXISTS task_evaluation_execution JSONB;
|
||||||
|
|
||||||
|
ALTER TABLE work.graph_nodes
|
||||||
|
ADD COLUMN IF NOT EXISTS task_evaluation_profile JSONB,
|
||||||
|
ADD COLUMN IF NOT EXISTS task_evaluation_cases JSONB NOT NULL DEFAULT '[]',
|
||||||
|
ADD COLUMN IF NOT EXISTS task_evaluation_execution JSONB;
|
||||||
|
|
||||||
|
RESET ROLE;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SET ROLE work_role;
|
||||||
|
|
||||||
|
ALTER TABLE work.agent_dispatch_queue
|
||||||
|
ADD COLUMN IF NOT EXISTS retry_limit INTEGER NOT NULL DEFAULT 3;
|
||||||
|
|
||||||
|
ALTER TABLE work.agent_dispatch_queue
|
||||||
|
DROP CONSTRAINT IF EXISTS agent_dispatch_queue_retry_limit_check;
|
||||||
|
|
||||||
|
ALTER TABLE work.agent_dispatch_queue
|
||||||
|
ADD CONSTRAINT agent_dispatch_queue_retry_limit_check CHECK (retry_limit > 0);
|
||||||
|
|
||||||
|
RESET ROLE;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SET ROLE work_role;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS work.extension_proposals (
|
||||||
|
proposal_id TEXT PRIMARY KEY,
|
||||||
|
requested_by_principal_id TEXT NOT NULL,
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
evidence_refs TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||||
|
deduplicated_signal_keys TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||||
|
desired_outcome TEXT NOT NULL,
|
||||||
|
smallest_change_layer TEXT NOT NULL,
|
||||||
|
affected_owners TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||||
|
affected_contracts TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||||
|
risk_tier TEXT NOT NULL,
|
||||||
|
evaluation_plan TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||||
|
alternatives TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||||
|
rollback_plan TEXT NOT NULL,
|
||||||
|
assumptions TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||||
|
created_at_unix_millis BIGINT NOT NULL,
|
||||||
|
CHECK (proposal_id <> ''), CHECK (requested_by_principal_id <> ''), CHECK (stage = 'PROPOSED'), CHECK (desired_outcome <> ''), CHECK (smallest_change_layer <> ''), CHECK (risk_tier <> ''), CHECK (rollback_plan <> ''), CHECK (created_at_unix_millis > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS work.development_scaffolds (
|
||||||
|
scaffold_id TEXT PRIMARY KEY,
|
||||||
|
requested_by_principal_id TEXT NOT NULL,
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
proposal_ref TEXT NOT NULL,
|
||||||
|
base_repo_revision TEXT NOT NULL,
|
||||||
|
worktree_ref TEXT NOT NULL,
|
||||||
|
task_intent TEXT NOT NULL,
|
||||||
|
modules TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||||
|
allowed_paths TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||||
|
forbidden_paths TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||||
|
required_tests TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||||
|
artifact_lineage_refs TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||||
|
protected_main_access TEXT NOT NULL,
|
||||||
|
production_access TEXT NOT NULL,
|
||||||
|
secret_access TEXT NOT NULL,
|
||||||
|
created_at_unix_millis BIGINT NOT NULL,
|
||||||
|
CHECK (scaffold_id <> ''), CHECK (requested_by_principal_id <> ''), CHECK (stage = 'SANDBOXED'), CHECK (proposal_ref <> ''), CHECK (base_repo_revision <> ''), CHECK (worktree_ref <> ''), CHECK (task_intent <> ''), CHECK (protected_main_access = 'DENIED'), CHECK (production_access = 'DENIED'), CHECK (secret_access = 'DENIED'), CHECK (created_at_unix_millis > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
RESET ROLE;
|
||||||
|
COMMIT;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"task_file": "dev/tasks/DEV-000212-task-evaluator-invocation.json",
|
"task_file": "dev/tasks/DEV-000215-governed-development-control-plane.json",
|
||||||
"task_id": "DEV-000212",
|
"task_id": "DEV-000215",
|
||||||
"task_revision": 1
|
"task_revision": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"task_id": "DEV-000213",
|
||||||
|
"task_revision": 1,
|
||||||
|
"architecture_revision": "ADR-0018",
|
||||||
|
"base_repo_revision": "a86d1994c3f48db86592eb87fb54533297e0671c",
|
||||||
|
"risk_tier": "E",
|
||||||
|
"intent": "Carry the complete bounded task-evaluation profile and case payload through durable Work Graph dispatch so agent-worker can invoke Cognitive Worker, aggregate observations, and return the existing Work Graph callback after restart-safe recovery.",
|
||||||
|
"requirement_ids": ["dispatch-to-evaluator link", "payload durability", "worker recovery", "observation aggregation", "callback integration"],
|
||||||
|
"allowed_change_scope": {"modules": ["work", "agent-worker"], "paths": ["contracts/work/**", "modules/work/**", "adapters/postgres/work_repository.go", "cmd/agent-worker/**", "cmd/work-graph-runtime/**", "database/migrations/041_task_evaluation_payload.sql", "tests/agent_execution/**", "tests/model_qualification/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000213-worker-evaluator-link.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": ["TaskEvaluationExecutionRequest/Response", "Work Graph callback", "existing AgentExecutor and recovery contract"], "may_change": ["typed dispatch payload and worker evaluator executor"]},
|
||||||
|
"semantic_owner": "work owns dispatch lifecycle; capability owns evaluation evidence; cognitive-worker owns provider invocation",
|
||||||
|
"canonical_state": ["work dispatch payload is durable input; Capability remains evidence owner"],
|
||||||
|
"state_transitions": ["task graph node -> durable payload dispatch", "worker claim -> evaluator invocation", "response -> aggregate TaskEvaluation", "aggregate -> Work Graph evidence callback"],
|
||||||
|
"side_effects_and_privileges": ["HTTP calls to internal cognitive/core/work boundaries; no direct foreign DB and no new secret"],
|
||||||
|
"persistence_and_migrations": ["041 adds JSONB payload columns to work graph nodes and dispatch intents"],
|
||||||
|
"failure_retry_restart": ["payload is loaded after stale claim recovery; evaluator failure leaves dispatch retryable", "callback only follows a persisted aggregate; unknown remains unknown"],
|
||||||
|
"assumptions": ["Cognitive Worker endpoint is available as optional local execution path", "external bridge remains preferred for non-evaluation agent tasks"],
|
||||||
|
"acceptance_scenarios": ["profile/cases survive save-load", "task dispatch invokes evaluator with original prompt payload", "all observations aggregate deterministically", "callback receives matching binding/profile/artifact", "evaluator or callback failure does not complete dispatch", "legacy dispatch remains unchanged"],
|
||||||
|
"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-000214",
|
||||||
|
"task_revision": 1,
|
||||||
|
"architecture_revision": "ADR-0018",
|
||||||
|
"base_repo_revision": "f9b9765100df02bead16280d2db9c1af478a4589",
|
||||||
|
"risk_tier": "E",
|
||||||
|
"intent": "Make Agent Execution recovery restart-safe: renew active leases with managed heartbeats, fence stale workers, cap retries, and requeue transient execution failures without losing durable dispatch state.",
|
||||||
|
"requirement_ids": ["durable heartbeat", "lease fencing", "bounded retry", "restart recovery", "transient failure requeue"],
|
||||||
|
"allowed_change_scope": {"modules": ["work", "agent-worker"], "paths": ["contracts/work/**", "modules/work/**", "adapters/postgres/work_repository.go", "cmd/agent-worker/**", "cmd/agent-canary/**", "database/migrations/042_agent_execution_recovery.sql", "tests/agent_execution/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000214-agent-execution-recovery.json"]},
|
||||||
|
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/devctl/**", "modules/identity/**", "modules/capability/**", "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": 1},
|
||||||
|
"contracts": {"consume": ["existing AgentExecutor", "existing lease/callback contract", "durable dispatch queue"], "may_change": ["WorkerStore retry/recovery port"]},
|
||||||
|
"semantic_owner": "work owns dispatch, lease, callback and retry lifecycle",
|
||||||
|
"canonical_state": ["work agent dispatch queue and lease tables remain canonical; heartbeat is durable evidence of liveness"],
|
||||||
|
"state_transitions": ["CLAIMED -> QUEUED on transient failure or stale recovery", "CLAIMED -> FAILED after retry limit", "ACTIVE lease -> EXPIRED before stale claim requeue", "heartbeat renews lease and callback sequence"],
|
||||||
|
"side_effects_and_privileges": ["work_role writes only work-owned queue and lease state; no external mutation"],
|
||||||
|
"persistence_and_migrations": ["042 adds bounded retry limit to dispatch queue"],
|
||||||
|
"failure_retry_restart": ["stale recovery expires the old lease before requeue", "transient execution failure uses durable delayed retry", "retry limit prevents hot-loop poison dispatch", "heartbeat failure cancels execution and leaves a retryable dispatch"],
|
||||||
|
"assumptions": ["operational heartbeat ticker is lifecycle-managed by the bounded worker call and durable state remains authoritative"],
|
||||||
|
"acceptance_scenarios": ["long execution renews lease and emits ordered heartbeats", "stale claim is fenced and requeued", "third failed attempt becomes FAILED", "waiting/error dispatch is not left CLAIMED", "legacy dispatch behavior remains compatible"],
|
||||||
|
"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-000215",
|
||||||
|
"task_revision": 1,
|
||||||
|
"architecture_revision": "ADR-0018",
|
||||||
|
"base_repo_revision": "8d3e7f7cac236aa50919bd1cd854012dcc8500a5",
|
||||||
|
"risk_tier": "E",
|
||||||
|
"intent": "Expose a durable, authenticated control-plane boundary for governed self-development proposals and isolated scaffold candidates without executing source mutation, bypassing verification, or granting promotion authority.",
|
||||||
|
"requirement_ids": ["durable proposal intake", "isolated scaffold contract", "authenticated operator boundary", "no implicit promotion"],
|
||||||
|
"allowed_change_scope": {"modules": ["work", "core-runtime"], "paths": ["contracts/work/self_development.go", "modules/work/domain/**", "cmd/core-runtime/main.go", "adapters/postgres/work_repository.go", "database/migrations/043_governed_development_candidates.sql", "tests/semantic_evolution/**", "tests/work/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000215-governed-development-control-plane.json"]},
|
||||||
|
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/devctl/**", "modules/identity/**", "modules/capability/**", "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": 1},
|
||||||
|
"contracts": {"consume": ["ADR-0022 governed self-development boundary", "creator authentication", "work development gate"], "may_change": ["work-owned proposal/scaffold persistence and authenticated control-plane endpoints"]},
|
||||||
|
"semantic_owner": "work owns proposal, scaffold and development gate lineage",
|
||||||
|
"canonical_state": ["work-owned extension proposal and scaffold candidates are durable; source tree and production remain outside this task"],
|
||||||
|
"state_transitions": ["repeated evidence -> PROPOSED", "proposal -> SANDBOXED scaffold candidate", "gate remains explicit and cannot be promoted by these endpoints"],
|
||||||
|
"side_effects_and_privileges": ["authenticated creator session required; database writes stay within work owner; no checkout, source mutation, deployment or secret access"],
|
||||||
|
"persistence_and_migrations": ["043 adds durable work-owned proposal/scaffold candidate records"],
|
||||||
|
"failure_retry_restart": ["idempotent candidate IDs preserve lineage; duplicate IDs are rejected; no endpoint advances promotion stage"],
|
||||||
|
"assumptions": ["an independent executor and verifier remain separate future capabilities"],
|
||||||
|
"acceptance_scenarios": ["unauthenticated requests are rejected", "repeated evidence creates a durable proposal", "protected paths cannot create a scaffold", "valid scaffold is durable and remains SANDBOXED", "no promotion or source mutation occurs"],
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package application
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.cin.su/m/Assistant/contracts/work"
|
"git.cin.su/m/Assistant/contracts/work"
|
||||||
@@ -33,16 +35,75 @@ func ExecuteAgentDispatch(ctx context.Context, store ports.ExecutionStore, execu
|
|||||||
if err := store.AcceptAgentCallback(ctx, heartbeat); err != nil {
|
if err := store.AcceptAgentCallback(ctx, heartbeat); err != nil {
|
||||||
return work.AgentExecutionResult{}, err
|
return work.AgentExecutionResult{}, err
|
||||||
}
|
}
|
||||||
result, err := executor.Execute(ctx, work.AgentExecutionRequest{Intent: intent, WorkerID: workerID, FencingToken: token, LeaseExpiresAt: lease.ExpiresAt})
|
executionCtx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
var stateMu sync.Mutex
|
||||||
|
currentLease := lease
|
||||||
|
lastSequence := heartbeat.Sequence
|
||||||
|
heartbeatDone := make(chan struct{})
|
||||||
|
heartbeatStopped := make(chan struct{})
|
||||||
|
heartbeatFailure := make(chan error, 1)
|
||||||
|
interval := ttl / 3
|
||||||
|
if interval <= 0 {
|
||||||
|
return work.AgentExecutionResult{}, fmt.Errorf("invalid heartbeat interval")
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer close(heartbeatStopped)
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-heartbeatDone:
|
||||||
|
return
|
||||||
|
case observedAt := <-ticker.C:
|
||||||
|
stateMu.Lock()
|
||||||
|
nextLease, renewErr := domain.RenewAgentLease(currentLease, workerID, token, observedAt, ttl)
|
||||||
|
if renewErr == nil {
|
||||||
|
renewErr = store.RenewAgentLease(executionCtx, nextLease, nextLease.ExpiresAt)
|
||||||
|
}
|
||||||
|
if renewErr == nil {
|
||||||
|
lastSequence++
|
||||||
|
heartbeat = work.AgentCallback{RunID: intent.RunID, WorkerID: workerID, FencingToken: token, Sequence: lastSequence, Kind: work.AgentCallbackHeartbeat, ObservedAt: observedAt}
|
||||||
|
renewErr = domain.ValidateAgentCallback(heartbeat, nextLease, lastSequence-1)
|
||||||
|
}
|
||||||
|
if renewErr == nil {
|
||||||
|
renewErr = store.AcceptAgentCallback(executionCtx, heartbeat)
|
||||||
|
}
|
||||||
|
if renewErr == nil {
|
||||||
|
currentLease = nextLease
|
||||||
|
}
|
||||||
|
stateMu.Unlock()
|
||||||
|
if renewErr != nil {
|
||||||
|
select {
|
||||||
|
case heartbeatFailure <- renewErr:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
result, err := executor.Execute(executionCtx, work.AgentExecutionRequest{Intent: intent, WorkerID: workerID, FencingToken: token, LeaseExpiresAt: lease.ExpiresAt})
|
||||||
|
close(heartbeatDone)
|
||||||
|
<-heartbeatStopped
|
||||||
|
select {
|
||||||
|
case heartbeatErr := <-heartbeatFailure:
|
||||||
|
return work.AgentExecutionResult{}, heartbeatErr
|
||||||
|
default:
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return work.AgentExecutionResult{}, err
|
return work.AgentExecutionResult{}, err
|
||||||
}
|
}
|
||||||
|
stateMu.Lock()
|
||||||
|
lease = currentLease
|
||||||
|
stateMu.Unlock()
|
||||||
observedAt := time.UnixMilli(result.ObservedAtUnixMillis)
|
observedAt := time.UnixMilli(result.ObservedAtUnixMillis)
|
||||||
callback := work.AgentCallback{RunID: intent.RunID, WorkerID: workerID, FencingToken: token, Sequence: 2, Kind: work.AgentCallbackResult, ObservedAt: observedAt}
|
callback := work.AgentCallback{RunID: intent.RunID, WorkerID: workerID, FencingToken: token, Sequence: lastSequence + 1, Kind: work.AgentCallbackResult, ObservedAt: observedAt}
|
||||||
if result.State == work.AgentExecutionWaiting {
|
if result.State == work.AgentExecutionWaiting {
|
||||||
callback.Kind = work.AgentCallbackCheckpoint
|
callback.Kind = work.AgentCallbackCheckpoint
|
||||||
}
|
}
|
||||||
if err := domain.ValidateAgentCallback(callback, lease, heartbeat.Sequence); err != nil {
|
if err := domain.ValidateAgentCallback(callback, lease, lastSequence); err != nil {
|
||||||
return work.AgentExecutionResult{}, err
|
return work.AgentExecutionResult{}, err
|
||||||
}
|
}
|
||||||
if err := store.AcceptAgentCallback(ctx, callback); err != nil {
|
if err := store.AcceptAgentCallback(ctx, callback); err != nil {
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ func (s *bridgeStore) SaveAgentLease(_ context.Context, lease work.AgentLease) e
|
|||||||
s.lease = lease
|
s.lease = lease
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (s *bridgeStore) RenewAgentLease(_ context.Context, lease work.AgentLease, _ time.Time) error {
|
||||||
|
s.lease = lease
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (s *bridgeStore) AcceptAgentCallback(_ context.Context, callback work.AgentCallback) error {
|
func (s *bridgeStore) AcceptAgentCallback(_ context.Context, callback work.AgentCallback) error {
|
||||||
s.callbacks = append(s.callbacks, callback)
|
s.callbacks = append(s.callbacks, callback)
|
||||||
return nil
|
return nil
|
||||||
@@ -29,6 +33,17 @@ func (bridgeExecutor) Execute(context.Context, work.AgentExecutionRequest) (work
|
|||||||
return work.AgentExecutionResult{State: work.AgentExecutionCompleted, Summary: "done", ObservedAtUnixMillis: 2}, nil
|
return work.AgentExecutionResult{State: work.AgentExecutionCompleted, Summary: "done", ObservedAtUnixMillis: 2}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type slowBridgeExecutor struct{}
|
||||||
|
|
||||||
|
func (slowBridgeExecutor) Execute(ctx context.Context, request work.AgentExecutionRequest) (work.AgentExecutionResult, error) {
|
||||||
|
select {
|
||||||
|
case <-time.After(45 * time.Millisecond):
|
||||||
|
return work.AgentExecutionResult{State: work.AgentExecutionCompleted, Summary: "done", ObservedAtUnixMillis: time.Now().UnixMilli()}, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return work.AgentExecutionResult{}, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteAgentDispatchPersistsLeaseAndBridgeCallbacks(t *testing.T) {
|
func TestExecuteAgentDispatchPersistsLeaseAndBridgeCallbacks(t *testing.T) {
|
||||||
store := &bridgeStore{}
|
store := &bridgeStore{}
|
||||||
now := time.UnixMilli(1)
|
now := time.UnixMilli(1)
|
||||||
@@ -40,3 +55,20 @@ func TestExecuteAgentDispatchPersistsLeaseAndBridgeCallbacks(t *testing.T) {
|
|||||||
t.Fatalf("callbacks=%+v", store.callbacks)
|
t.Fatalf("callbacks=%+v", store.callbacks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteAgentDispatchRenewsLeaseAndOrdersHeartbeats(t *testing.T) {
|
||||||
|
store := &bridgeStore{}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_, err := ExecuteAgentDispatch(context.Background(), store, slowBridgeExecutor{}, work.AgentDispatchIntent{DispatchID: "d-slow", IdempotencyKey: "i-slow", DelegationID: "del-slow", RunID: "run-slow", ImplementationID: "impl", ImplementationRevision: 1, SourceRevision: "source", WorktreeID: "tree", VerificationPlanRef: "verify", CreatedAtUnixMillis: now.UnixMilli()}, "worker", 1, now, 30*time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(store.callbacks) < 3 {
|
||||||
|
t.Fatalf("expected heartbeat callbacks, got %d", len(store.callbacks))
|
||||||
|
}
|
||||||
|
for i, callback := range store.callbacks {
|
||||||
|
if callback.Sequence != uint64(i+1) {
|
||||||
|
t.Fatalf("callback sequence is not ordered: %#v", store.callbacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/authority"
|
||||||
|
"git.cin.su/m/Assistant/contracts/capability"
|
||||||
|
"git.cin.su/m/Assistant/contracts/identity"
|
||||||
"git.cin.su/m/Assistant/contracts/work"
|
"git.cin.su/m/Assistant/contracts/work"
|
||||||
"git.cin.su/m/Assistant/modules/work/domain"
|
"git.cin.su/m/Assistant/modules/work/domain"
|
||||||
"git.cin.su/m/Assistant/modules/work/ports"
|
"git.cin.su/m/Assistant/modules/work/ports"
|
||||||
@@ -157,6 +160,13 @@ func AdmitAndScheduleTaskEvaluation(ctx context.Context, store ports.GraphStore,
|
|||||||
intents[i].TaskEvaluationProfileID = node.TaskProfileID
|
intents[i].TaskEvaluationProfileID = node.TaskProfileID
|
||||||
intents[i].TaskEvaluationBindingID = node.BindingID
|
intents[i].TaskEvaluationBindingID = node.BindingID
|
||||||
intents[i].TaskEvaluationCaseIDs = append([]string(nil), node.CaseIDs...)
|
intents[i].TaskEvaluationCaseIDs = append([]string(nil), node.CaseIDs...)
|
||||||
|
if node.TaskProfile != nil {
|
||||||
|
profile := *node.TaskProfile
|
||||||
|
profile.RequiredCaseIDs = append([]string(nil), node.TaskProfile.RequiredCaseIDs...)
|
||||||
|
intents[i].TaskEvaluationProfile = &profile
|
||||||
|
}
|
||||||
|
intents[i].TaskEvaluationCases = append([]capability.TaskEvaluationCase(nil), node.TaskCases...)
|
||||||
|
intents[i].TaskEvaluationExecution = &work.TaskEvaluationDispatchContext{GraphID: graph.ID, GraphRevision: graph.Revision, NodeID: node.ID, BindingRevision: request.Plan.BindingRevision, ActorID: identity.ActorID(request.Plan.ActorID), PrincipalID: identity.PrincipalID(request.Plan.PrincipalID), ScopeID: authority.ScopeID(request.Plan.ScopeID)}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ func TestAdmitAndScheduleTaskEvaluationCarriesSpecialistContext(t *testing.T) {
|
|||||||
request := TaskEvaluationScheduleRequest{
|
request := TaskEvaluationScheduleRequest{
|
||||||
Plan: work.TaskEvaluationGraphRequest{
|
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},
|
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},
|
||||||
|
Cases: []capability.TaskEvaluationCase{{CaseID: "case-a", Prompt: "one", EvidenceArtifactRef: "artifact-a"}, {CaseID: "case-b", Prompt: "two", EvidenceArtifactRef: "artifact-b"}},
|
||||||
BindingIDs: []capability.CapabilityBindingID{"binding-a"}, DispatchIDs: []string{"dispatch-a"}, IdempotencyPrefix: "eval", Budget: work.GraphBudget{MaxParallel: 1, MaxAttempts: 2},
|
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}},
|
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}},
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ type WorkerCycleResult struct {
|
|||||||
Idle bool
|
Idle bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const transientDispatchRetryDelay = time.Second
|
||||||
|
|
||||||
|
func retryClaimedDispatch(ctx context.Context, store ports.WorkerStore, dispatchID, workerID string, now time.Time) error {
|
||||||
|
return store.RetryDispatch(ctx, dispatchID, workerID, now.UnixMilli(), transientDispatchRetryDelay)
|
||||||
|
}
|
||||||
|
|
||||||
// RecoverOnlyWorkerCycle keeps durable recovery alive while the real executor
|
// RecoverOnlyWorkerCycle keeps durable recovery alive while the real executor
|
||||||
// capability is absent. It intentionally does not claim a dispatch: a missing
|
// capability is absent. It intentionally does not claim a dispatch: a missing
|
||||||
// executor must never be represented as successful work.
|
// executor must never be represented as successful work.
|
||||||
@@ -37,11 +43,11 @@ func ExecuteWorkerCycle(ctx context.Context, store ports.WorkerStore, workerID s
|
|||||||
}
|
}
|
||||||
intent, err := store.LoadDispatchIntent(ctx, item.DispatchID)
|
intent, err := store.LoadDispatchIntent(ctx, item.DispatchID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = store.CompleteDispatch(ctx, item.DispatchID, workerID, work.AgentDispatchFailed, now.UnixMilli())
|
_ = retryClaimedDispatch(ctx, store, item.DispatchID, workerID, now)
|
||||||
return WorkerCycleResult{RecoveredClaims: recovered, DispatchID: item.DispatchID}, err
|
return WorkerCycleResult{RecoveredClaims: recovered, DispatchID: item.DispatchID}, err
|
||||||
}
|
}
|
||||||
if _, err := ExecuteDryRunDispatch(ctx, store, intent, workerID, token, now, leaseTTL); err != nil {
|
if _, err := ExecuteDryRunDispatch(ctx, store, intent, workerID, token, now, leaseTTL); err != nil {
|
||||||
_ = store.CompleteDispatch(ctx, item.DispatchID, workerID, work.AgentDispatchFailed, now.UnixMilli())
|
_ = retryClaimedDispatch(ctx, store, item.DispatchID, workerID, now)
|
||||||
return WorkerCycleResult{RecoveredClaims: recovered, DispatchID: item.DispatchID}, err
|
return WorkerCycleResult{RecoveredClaims: recovered, DispatchID: item.DispatchID}, err
|
||||||
}
|
}
|
||||||
if err := store.CompleteDispatch(ctx, item.DispatchID, workerID, work.AgentDispatchCompleted, now.UnixMilli()); err != nil {
|
if err := store.CompleteDispatch(ctx, item.DispatchID, workerID, work.AgentDispatchCompleted, now.UnixMilli()); err != nil {
|
||||||
@@ -64,15 +70,18 @@ func ExecuteWorkerCycleWithExecutor(ctx context.Context, store ports.WorkerStore
|
|||||||
}
|
}
|
||||||
intent, err := store.LoadDispatchIntent(ctx, item.DispatchID)
|
intent, err := store.LoadDispatchIntent(ctx, item.DispatchID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = store.CompleteDispatch(ctx, item.DispatchID, workerID, work.AgentDispatchFailed, now.UnixMilli())
|
_ = retryClaimedDispatch(ctx, store, item.DispatchID, workerID, now)
|
||||||
return WorkerCycleResult{RecoveredClaims: recovered, DispatchID: item.DispatchID}, err
|
return WorkerCycleResult{RecoveredClaims: recovered, DispatchID: item.DispatchID}, err
|
||||||
}
|
}
|
||||||
result, err := ExecuteAgentDispatch(ctx, store, executor, intent, workerID, token, now, leaseTTL)
|
result, err := ExecuteAgentDispatch(ctx, store, executor, intent, workerID, token, now, leaseTTL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = store.CompleteDispatch(ctx, item.DispatchID, workerID, work.AgentDispatchFailed, now.UnixMilli())
|
_ = retryClaimedDispatch(ctx, store, item.DispatchID, workerID, now)
|
||||||
return WorkerCycleResult{RecoveredClaims: recovered, DispatchID: item.DispatchID}, err
|
return WorkerCycleResult{RecoveredClaims: recovered, DispatchID: item.DispatchID}, err
|
||||||
}
|
}
|
||||||
if result.State != work.AgentExecutionCompleted {
|
if result.State != work.AgentExecutionCompleted {
|
||||||
|
if err := retryClaimedDispatch(ctx, store, item.DispatchID, workerID, now); err != nil {
|
||||||
|
return WorkerCycleResult{RecoveredClaims: recovered, DispatchID: item.DispatchID}, err
|
||||||
|
}
|
||||||
return WorkerCycleResult{RecoveredClaims: recovered, DispatchID: item.DispatchID}, nil
|
return WorkerCycleResult{RecoveredClaims: recovered, DispatchID: item.DispatchID}, nil
|
||||||
}
|
}
|
||||||
if err := store.CompleteDispatch(ctx, item.DispatchID, workerID, work.AgentDispatchCompleted, now.UnixMilli()); err != nil {
|
if err := store.CompleteDispatch(ctx, item.DispatchID, workerID, work.AgentDispatchCompleted, now.UnixMilli()); err != nil {
|
||||||
|
|||||||
@@ -26,10 +26,16 @@ func (s *recoveryOnlyStore) LoadDispatchIntent(context.Context, string) (work.Ag
|
|||||||
func (s *recoveryOnlyStore) CompleteDispatch(context.Context, string, string, work.AgentDispatchQueueState, int64) error {
|
func (s *recoveryOnlyStore) CompleteDispatch(context.Context, string, string, work.AgentDispatchQueueState, int64) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (s *recoveryOnlyStore) RetryDispatch(context.Context, string, string, int64, time.Duration) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (s *recoveryOnlyStore) SaveDispatchIntent(context.Context, work.AgentDispatchIntent) error {
|
func (s *recoveryOnlyStore) SaveDispatchIntent(context.Context, work.AgentDispatchIntent) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (s *recoveryOnlyStore) SaveAgentLease(context.Context, work.AgentLease) error { return nil }
|
func (s *recoveryOnlyStore) SaveAgentLease(context.Context, work.AgentLease) error { return nil }
|
||||||
|
func (s *recoveryOnlyStore) RenewAgentLease(context.Context, work.AgentLease, time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (s *recoveryOnlyStore) AcceptAgentCallback(context.Context, work.AgentCallback) error {
|
func (s *recoveryOnlyStore) AcceptAgentCallback(context.Context, work.AgentCallback) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,24 @@ func BuildTaskEvaluationGraph(request work.TaskEvaluationGraphRequest) (work.Wor
|
|||||||
if err := request.Profile.Validate(); err != nil {
|
if err := request.Profile.Validate(); err != nil {
|
||||||
return work.WorkGraph{}, ErrInvalidTaskEvaluationGraph
|
return work.WorkGraph{}, ErrInvalidTaskEvaluationGraph
|
||||||
}
|
}
|
||||||
|
if len(request.Cases) != len(request.Profile.RequiredCaseIDs) {
|
||||||
|
return work.WorkGraph{}, ErrInvalidTaskEvaluationGraph
|
||||||
|
}
|
||||||
|
seenCases := make(map[string]struct{}, len(request.Cases))
|
||||||
|
for _, item := range request.Cases {
|
||||||
|
if item.CaseID == "" || item.Prompt == "" || item.EvidenceArtifactRef == "" {
|
||||||
|
return work.WorkGraph{}, ErrInvalidTaskEvaluationGraph
|
||||||
|
}
|
||||||
|
if _, exists := seenCases[item.CaseID]; exists {
|
||||||
|
return work.WorkGraph{}, ErrInvalidTaskEvaluationGraph
|
||||||
|
}
|
||||||
|
seenCases[item.CaseID] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, requiredID := range request.Profile.RequiredCaseIDs {
|
||||||
|
if _, exists := seenCases[requiredID]; !exists {
|
||||||
|
return work.WorkGraph{}, ErrInvalidTaskEvaluationGraph
|
||||||
|
}
|
||||||
|
}
|
||||||
if request.Profile.Stage == capability.TaskEvaluationGeneral {
|
if request.Profile.Stage == capability.TaskEvaluationGeneral {
|
||||||
if request.ParentGraphID != "" || request.ParentGraphRevision != 0 || request.ParentGraphState != "" || request.TriggerEvidenceRef != "" {
|
if request.ParentGraphID != "" || request.ParentGraphRevision != 0 || request.ParentGraphState != "" || request.TriggerEvidenceRef != "" {
|
||||||
return work.WorkGraph{}, ErrInvalidTaskEvaluationGraph
|
return work.WorkGraph{}, ErrInvalidTaskEvaluationGraph
|
||||||
@@ -56,7 +74,15 @@ func BuildTaskEvaluationGraph(request work.TaskEvaluationGraphRequest) (work.Wor
|
|||||||
TaskProfileID: request.Profile.ID,
|
TaskProfileID: request.Profile.ID,
|
||||||
BindingID: bindingID,
|
BindingID: bindingID,
|
||||||
CaseIDs: append([]string(nil), request.Profile.RequiredCaseIDs...),
|
CaseIDs: append([]string(nil), request.Profile.RequiredCaseIDs...),
|
||||||
|
TaskProfile: cloneTaskEvaluationProfile(request.Profile),
|
||||||
|
TaskCases: append([]capability.TaskEvaluationCase(nil), request.Cases...),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
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}, nil
|
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}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneTaskEvaluationProfile(value capability.TaskEvaluationProfile) *capability.TaskEvaluationProfile {
|
||||||
|
copyValue := value
|
||||||
|
copyValue.RequiredCaseIDs = append([]string(nil), value.RequiredCaseIDs...)
|
||||||
|
return ©Value
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
func evaluationGraphRequest(stage capability.TaskEvaluationProfileStage) work.TaskEvaluationGraphRequest {
|
func evaluationGraphRequest(stage capability.TaskEvaluationProfileStage) work.TaskEvaluationGraphRequest {
|
||||||
profile := capability.TaskEvaluationProfile{ID: "profile-1", Revision: 1, CapabilityID: "capability-1", Stage: stage, BenchmarkVersion: "general-v1", RequiredCaseIDs: []string{"case-a", "case-b"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
|
profile := capability.TaskEvaluationProfile{ID: "profile-1", Revision: 1, CapabilityID: "capability-1", Stage: stage, BenchmarkVersion: "general-v1", RequiredCaseIDs: []string{"case-a", "case-b"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
|
||||||
request := work.TaskEvaluationGraphRequest{GraphID: "graph-1", Profile: profile, BindingIDs: []capability.CapabilityBindingID{"binding-a", "binding-b"}, DispatchIDs: []string{"dispatch-a", "dispatch-b"}, IdempotencyPrefix: "task-eval", Budget: work.GraphBudget{MaxParallel: 2, MaxAttempts: 2}}
|
request := work.TaskEvaluationGraphRequest{GraphID: "graph-1", Profile: profile, BindingIDs: []capability.CapabilityBindingID{"binding-a", "binding-b"}, DispatchIDs: []string{"dispatch-a", "dispatch-b"}, IdempotencyPrefix: "task-eval", Budget: work.GraphBudget{MaxParallel: 2, MaxAttempts: 2}, Cases: []capability.TaskEvaluationCase{{CaseID: "case-a", Prompt: "short answer", EvidenceArtifactRef: "artifact-a"}, {CaseID: "case-b", Prompt: "long answer", EvidenceArtifactRef: "artifact-b"}}}
|
||||||
if stage == capability.TaskEvaluationAdditional {
|
if stage == capability.TaskEvaluationAdditional {
|
||||||
request.Profile.ParentProfileID = "profile-general"
|
request.Profile.ParentProfileID = "profile-general"
|
||||||
request.Profile.TriggerEvidenceRef = "artifact:unresolved-1"
|
request.Profile.TriggerEvidenceRef = "artifact:unresolved-1"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package ports
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.cin.su/m/Assistant/contracts/work"
|
"git.cin.su/m/Assistant/contracts/work"
|
||||||
)
|
)
|
||||||
@@ -13,5 +14,6 @@ type AgentExecutor interface {
|
|||||||
type ExecutionStore interface {
|
type ExecutionStore interface {
|
||||||
SaveDispatchIntent(context.Context, work.AgentDispatchIntent) error
|
SaveDispatchIntent(context.Context, work.AgentDispatchIntent) error
|
||||||
SaveAgentLease(context.Context, work.AgentLease) error
|
SaveAgentLease(context.Context, work.AgentLease) error
|
||||||
|
RenewAgentLease(context.Context, work.AgentLease, time.Time) error
|
||||||
AcceptAgentCallback(context.Context, work.AgentCallback) error
|
AcceptAgentCallback(context.Context, work.AgentCallback) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ports
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.cin.su/m/Assistant/contracts/work"
|
"git.cin.su/m/Assistant/contracts/work"
|
||||||
)
|
)
|
||||||
@@ -14,5 +15,6 @@ type WorkerStore interface {
|
|||||||
LoadDispatchIntent(context.Context, string) (work.AgentDispatchIntent, error)
|
LoadDispatchIntent(context.Context, string) (work.AgentDispatchIntent, error)
|
||||||
ClaimNextDispatch(context.Context, string, int64) (work.AgentDispatchQueueItem, error)
|
ClaimNextDispatch(context.Context, string, int64) (work.AgentDispatchQueueItem, error)
|
||||||
CompleteDispatch(context.Context, string, string, work.AgentDispatchQueueState, int64) error
|
CompleteDispatch(context.Context, string, string, work.AgentDispatchQueueState, int64) error
|
||||||
|
RetryDispatch(context.Context, string, string, int64, time.Duration) error
|
||||||
RecoverStaleClaims(context.Context, int64, int64) (int64, error)
|
RecoverStaleClaims(context.Context, int64, int64) (int64, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ func (s *canaryStore) SaveDispatchIntent(context.Context, work.AgentDispatchInte
|
|||||||
s.writes++
|
s.writes++
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (s *canaryStore) SaveAgentLease(context.Context, work.AgentLease) error { s.writes++; return nil }
|
func (s *canaryStore) SaveAgentLease(context.Context, work.AgentLease) error { s.writes++; return nil }
|
||||||
|
func (s *canaryStore) RenewAgentLease(context.Context, work.AgentLease, time.Time) error { return nil }
|
||||||
func (s *canaryStore) AcceptAgentCallback(context.Context, work.AgentCallback) error {
|
func (s *canaryStore) AcceptAgentCallback(context.Context, work.AgentCallback) error {
|
||||||
s.writes++
|
s.writes++
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ func (s *recordingExecutionStore) SaveAgentLease(context.Context, work.AgentLeas
|
|||||||
s.operations = append(s.operations, "lease")
|
s.operations = append(s.operations, "lease")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (s *recordingExecutionStore) RenewAgentLease(context.Context, work.AgentLease, time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (s *recordingExecutionStore) AcceptAgentCallback(context.Context, work.AgentCallback) error {
|
func (s *recordingExecutionStore) AcceptAgentCallback(context.Context, work.AgentCallback) error {
|
||||||
s.operations = append(s.operations, "callback")
|
s.operations = append(s.operations, "callback")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package agent_execution_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecoveryMigrationDefinesBoundedRetryState(t *testing.T) {
|
||||||
|
payload, err := os.ReadFile("../../database/migrations/042_agent_execution_recovery.sql")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source := strings.ToLower(string(payload))
|
||||||
|
for _, fragment := range []string{"set role work_role", "retry_limit integer not null default 3", "retry_limit > 0"} {
|
||||||
|
if !strings.Contains(source, fragment) {
|
||||||
|
t.Fatalf("migration is missing %q", fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package semantic_evolution_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGovernedDevelopmentCandidateMigrationIsWorkOwnedAndSandboxed(t *testing.T) {
|
||||||
|
payload, err := os.ReadFile("../../database/migrations/043_governed_development_candidates.sql")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source := strings.ToLower(string(payload))
|
||||||
|
for _, fragment := range []string{"set role work_role", "create table if not exists work.extension_proposals", "create table if not exists work.development_scaffolds", "stage = 'sandboxed'", "protected_main_access = 'denied'", "production_access = 'denied'", "secret_access = 'denied'"} {
|
||||||
|
if !strings.Contains(source, fragment) {
|
||||||
|
t.Fatalf("migration is missing %q", fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user