Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79c99289e7 | ||
|
|
07a8de9cb5 | ||
|
|
fdf38196f0 | ||
|
|
b753761e1a | ||
|
|
5faa5321dd | ||
|
|
7271d7950d | ||
|
|
533243da25 | ||
|
|
b1fba3ccef | ||
|
|
b0afa2417f | ||
|
|
6f5f7e1ee3 | ||
|
|
94c5bcb017 | ||
|
|
3f7c4705fe | ||
|
|
1ccd43c552 | ||
|
|
d38406fbc5 | ||
|
|
f73c69960c | ||
|
|
32e33e3050 | ||
|
|
e9bb42cd07 | ||
|
|
4a800df1e1 | ||
|
|
b2f922cbbc |
@@ -3,6 +3,7 @@ package postgres
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -64,7 +65,7 @@ func (r *EpistemicsRepository) RecentReflectionCandidates(ctx context.Context, l
|
|||||||
}
|
}
|
||||||
rows, err := r.pool.Query(ctx, `SELECT
|
rows, err := r.pool.Query(ctx, `SELECT
|
||||||
reflection_candidate_id,trigger,state,source_experience_id,original_intent_id,source_utterance_id,
|
reflection_candidate_id,trigger,state,source_experience_id,original_intent_id,source_utterance_id,
|
||||||
summary,evidence_refs,requires_cognition,recorded_at_unix_millis,revision
|
summary,evidence_refs,requires_cognition,recorded_at_unix_millis,revision,claimed_by,claim_expires_at_unix_millis
|
||||||
FROM epistemics.reflection_candidates ORDER BY recorded_at_unix_millis DESC, reflection_candidate_id DESC LIMIT $1`, limit)
|
FROM epistemics.reflection_candidates ORDER BY recorded_at_unix_millis DESC, reflection_candidate_id DESC LIMIT $1`, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -74,7 +75,7 @@ func (r *EpistemicsRepository) RecentReflectionCandidates(ctx context.Context, l
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var value epistemics.ReflectionCandidate
|
var value epistemics.ReflectionCandidate
|
||||||
if err := rows.Scan(&value.ID, &value.Trigger, &value.State, &value.SourceExperienceID, &value.OriginalIntentID, &value.SourceUtteranceID,
|
if err := rows.Scan(&value.ID, &value.Trigger, &value.State, &value.SourceExperienceID, &value.OriginalIntentID, &value.SourceUtteranceID,
|
||||||
&value.Summary, &value.EvidenceRefs, &value.RequiresCognition, &value.RecordedAtUnixMillis, &value.Revision); err != nil {
|
&value.Summary, &value.EvidenceRefs, &value.RequiresCognition, &value.RecordedAtUnixMillis, &value.Revision, &value.ClaimedBy, &value.ClaimExpiresAtUnixMillis); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result = append(result, value)
|
result = append(result, value)
|
||||||
@@ -82,6 +83,61 @@ func (r *EpistemicsRepository) RecentReflectionCandidates(ctx context.Context, l
|
|||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *EpistemicsRepository) ClaimReflectionCandidate(ctx context.Context, workerID string, nowUnixMillis, leaseMillis int64) (epistemics.ReflectionCandidate, error) {
|
||||||
|
if workerID == "" || leaseMillis < 1 {
|
||||||
|
return epistemics.ReflectionCandidate{}, fmt.Errorf("invalid reflection claim")
|
||||||
|
}
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return epistemics.ReflectionCandidate{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
var value epistemics.ReflectionCandidate
|
||||||
|
err = tx.QueryRow(ctx, `SELECT reflection_candidate_id,trigger,state,source_experience_id,original_intent_id,source_utterance_id,summary,evidence_refs,requires_cognition,recorded_at_unix_millis,revision,claimed_by,claim_expires_at_unix_millis
|
||||||
|
FROM epistemics.reflection_candidates
|
||||||
|
WHERE state='OPEN' AND (claim_expires_at_unix_millis=0 OR claim_expires_at_unix_millis < $1)
|
||||||
|
ORDER BY recorded_at_unix_millis,reflection_candidate_id
|
||||||
|
FOR UPDATE SKIP LOCKED LIMIT 1`, nowUnixMillis).Scan(&value.ID, &value.Trigger, &value.State, &value.SourceExperienceID, &value.OriginalIntentID, &value.SourceUtteranceID, &value.Summary, &value.EvidenceRefs, &value.RequiresCognition, &value.RecordedAtUnixMillis, &value.Revision, &value.ClaimedBy, &value.ClaimExpiresAtUnixMillis)
|
||||||
|
if err != nil {
|
||||||
|
return epistemics.ReflectionCandidate{}, err
|
||||||
|
}
|
||||||
|
value.State = epistemics.ReflectionCandidateEvaluating
|
||||||
|
value.ClaimedBy = workerID
|
||||||
|
value.ClaimExpiresAtUnixMillis = nowUnixMillis + leaseMillis
|
||||||
|
_, err = tx.Exec(ctx, `UPDATE epistemics.reflection_candidates SET state=$1,claimed_by=$2,claim_expires_at_unix_millis=$3 WHERE reflection_candidate_id=$4 AND state='OPEN'`, value.State, value.ClaimedBy, value.ClaimExpiresAtUnixMillis, value.ID)
|
||||||
|
if err != nil {
|
||||||
|
return epistemics.ReflectionCandidate{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return epistemics.ReflectionCandidate{}, err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EpistemicsRepository) RecoverReflectionClaims(ctx context.Context, nowUnixMillis int64) (int64, error) {
|
||||||
|
result, err := r.pool.Exec(ctx, `UPDATE epistemics.reflection_candidates SET state='OPEN',claimed_by='',claim_expires_at_unix_millis=0 WHERE state='EVALUATING' AND claim_expires_at_unix_millis > 0 AND claim_expires_at_unix_millis < $1`, nowUnixMillis)
|
||||||
|
return result.RowsAffected(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EpistemicsRepository) ResolveReflectionCandidate(ctx context.Context, id epistemics.ReflectionCandidateID, workerID string, nowUnixMillis int64) error {
|
||||||
|
if id == "" || workerID == "" {
|
||||||
|
return fmt.Errorf("invalid reflection resolution")
|
||||||
|
}
|
||||||
|
result, err := r.pool.Exec(ctx, `UPDATE epistemics.reflection_candidates SET state='RESOLVED',claimed_by='',claim_expires_at_unix_millis=0,revision=revision+1 WHERE reflection_candidate_id=$1 AND state='EVALUATING' AND claimed_by=$2 AND claim_expires_at_unix_millis >= $3`, id, workerID, nowUnixMillis)
|
||||||
|
if err == nil && result.RowsAffected() != 1 {
|
||||||
|
return fmt.Errorf("reflection candidate claim lost")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EpistemicsRepository) WaitForReflectionCandidate(ctx context.Context, interval time.Duration) error {
|
||||||
|
if interval <= 0 {
|
||||||
|
return fmt.Errorf("invalid reflection poll interval")
|
||||||
|
}
|
||||||
|
_, err := r.pool.Exec(ctx, `SELECT pg_sleep($1)`, interval.Seconds())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *EpistemicsRepository) SaveExperienceRecord(ctx context.Context, value epistemics.ExperienceRecord) error {
|
func (r *EpistemicsRepository) SaveExperienceRecord(ctx context.Context, value epistemics.ExperienceRecord) error {
|
||||||
_, err := r.pool.Exec(ctx, `INSERT INTO epistemics.experience_records (
|
_, err := r.pool.Exec(ctx, `INSERT INTO epistemics.experience_records (
|
||||||
experience_id, source_kind, original_intent_id, source_utterance_id, context_refs, help_method, help_method_ref,
|
experience_id, source_kind, original_intent_id, source_utterance_id, context_refs, help_method, help_method_ref,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.cin.su/m/Assistant/contracts/work"
|
"git.cin.su/m/Assistant/contracts/work"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -91,6 +92,9 @@ func (r *WorkRepository) ClaimNextDispatch(ctx context.Context, workerID string,
|
|||||||
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 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) {
|
||||||
|
return work.AgentDispatchQueueItem{}, nil
|
||||||
|
}
|
||||||
return work.AgentDispatchQueueItem{}, err
|
return work.AgentDispatchQueueItem{}, err
|
||||||
}
|
}
|
||||||
item.State = work.AgentDispatchClaimed
|
item.State = work.AgentDispatchClaimed
|
||||||
@@ -107,6 +111,14 @@ func (r *WorkRepository) ClaimNextDispatch(ctx context.Context, workerID string,
|
|||||||
return item, nil
|
return item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *WorkRepository) WaitForDispatch(ctx context.Context, interval time.Duration) error {
|
||||||
|
if interval <= 0 {
|
||||||
|
return fmt.Errorf("invalid worker poll interval")
|
||||||
|
}
|
||||||
|
_, err := r.pool.Exec(ctx, `SELECT pg_sleep($1)`, interval.Seconds())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *WorkRepository) CompleteDispatch(ctx context.Context, dispatchID, workerID string, state work.AgentDispatchQueueState, nowUnixMillis int64) error {
|
func (r *WorkRepository) CompleteDispatch(ctx context.Context, dispatchID, workerID string, state work.AgentDispatchQueueState, nowUnixMillis int64) error {
|
||||||
if state != work.AgentDispatchCompleted && state != work.AgentDispatchFailed {
|
if state != work.AgentDispatchCompleted && state != work.AgentDispatchFailed {
|
||||||
return fmt.Errorf("invalid terminal dispatch state: %s", state)
|
return fmt.Errorf("invalid terminal dispatch state: %s", state)
|
||||||
@@ -121,6 +133,71 @@ func (r *WorkRepository) CompleteDispatch(ctx context.Context, dispatchID, worke
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *WorkRepository) SaveWorkGraph(ctx context.Context, graph work.WorkGraph) error {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, node := range graph.Nodes {
|
||||||
|
if _, err := tx.Exec(ctx, `INSERT INTO work.graph_nodes(graph_id,revision,node_id,idempotency_key,state,attempts) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(graph_id,revision,node_id) DO NOTHING`, graph.ID, graph.Revision, node.ID, node.IdempotencyKey, node.State, node.Attempts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, edge := range graph.Edges {
|
||||||
|
if _, err := tx.Exec(ctx, `INSERT INTO work.graph_edges(graph_id,revision,from_node_id,to_node_id) VALUES($1,$2,$3,$4) ON CONFLICT(graph_id,revision,from_node_id,to_node_id) DO NOTHING`, graph.ID, graph.Revision, edge.From, edge.To); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return work.WorkGraph{}, fmt.Errorf("load graph: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := r.pool.Query(ctx, `SELECT node_id,idempotency_key,state,attempts 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.IdempotencyKey, &node.State, &node.Attempts); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return work.WorkGraph{}, err
|
||||||
|
}
|
||||||
|
graph.Nodes = append(graph.Nodes, node)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return work.WorkGraph{}, err
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
edges, err := r.pool.Query(ctx, `SELECT from_node_id,to_node_id FROM work.graph_edges WHERE graph_id=$1 AND revision=$2 ORDER BY from_node_id,to_node_id`, graphID, revision)
|
||||||
|
if err != nil {
|
||||||
|
return work.WorkGraph{}, err
|
||||||
|
}
|
||||||
|
for edges.Next() {
|
||||||
|
var edge work.GraphEdge
|
||||||
|
if err := edges.Scan(&edge.From, &edge.To); err != nil {
|
||||||
|
edges.Close()
|
||||||
|
return work.WorkGraph{}, err
|
||||||
|
}
|
||||||
|
graph.Edges = append(graph.Edges, edge)
|
||||||
|
}
|
||||||
|
if err := edges.Err(); err != nil {
|
||||||
|
edges.Close()
|
||||||
|
return work.WorkGraph{}, err
|
||||||
|
}
|
||||||
|
edges.Close()
|
||||||
|
return graph, nil
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -9,29 +9,68 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.cin.su/m/Assistant/adapters/postgres"
|
"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/contracts/work"
|
||||||
"git.cin.su/m/Assistant/modules/work/application"
|
"git.cin.su/m/Assistant/modules/work/application"
|
||||||
|
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
workerID := flag.String("worker-id", "agent-worker-1", "durable worker identity")
|
workerID := flag.String("worker-id", "agent-worker-1", "durable worker identity")
|
||||||
staleAfter := flag.Duration("stale-after", 10*time.Minute, "age after which a claimed item is recovered")
|
staleAfter := flag.Duration("stale-after", 10*time.Minute, "age after which a claimed item is recovered")
|
||||||
leaseTTL := flag.Duration("lease-ttl", 15*time.Minute, "dry-run lease duration")
|
leaseTTL := flag.Duration("lease-ttl", 15*time.Minute, "dry-run lease duration")
|
||||||
|
pollInterval := flag.Duration("poll-interval", 5*time.Second, "database-owned idle wake-up interval")
|
||||||
|
once := flag.Bool("once", false, "run one cycle and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
repository, err := postgres.OpenWork(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("WORK_POSTGRES_USER"), Password: os.Getenv("WORK_POSTGRES_PASSWORD")})
|
password, err := resolveDatabasePassword("WORK_POSTGRES_PASSWORD", "WORK_POSTGRES_CREDENTIAL_REF", secretcontracts.RuntimeConsumerWorkWorker)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
repository, err := postgres.OpenWork(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("WORK_POSTGRES_USER"), Password: password})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer repository.Close()
|
defer repository.Close()
|
||||||
result, err := application.ExecuteWorkerCycle(context.Background(), repository, *workerID, work.AgentFencingToken(1), time.Now().UTC(), *staleAfter, *leaseTTL)
|
ctx := context.Background()
|
||||||
if err != nil {
|
for {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
result, cycleErr := application.ExecuteWorkerCycle(ctx, repository, *workerID, work.AgentFencingToken(1), time.Now().UTC(), *staleAfter, *leaseTTL)
|
||||||
os.Exit(1)
|
if cycleErr != nil {
|
||||||
}
|
fmt.Fprintln(os.Stderr, cycleErr)
|
||||||
if err := json.NewEncoder(os.Stdout).Encode(result); err != nil {
|
if *once {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
os.Exit(1)
|
||||||
os.Exit(1)
|
}
|
||||||
|
}
|
||||||
|
if cycleErr == nil && (result.Completed || result.RecoveredClaims > 0) {
|
||||||
|
_ = json.NewEncoder(os.Stdout).Encode(result)
|
||||||
|
}
|
||||||
|
if *once {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := repository.WaitForDispatch(ctx, *pollInterval); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveDatabasePassword(name, refName string, consumer secretcontracts.RuntimeConsumer) (string, error) {
|
||||||
|
if _, ok := os.LookupEnv(name); ok {
|
||||||
|
return "", fmt.Errorf("%s direct environment secret is forbidden", name)
|
||||||
|
}
|
||||||
|
path, ref := os.Getenv(name+"_FILE"), os.Getenv(refName)
|
||||||
|
if path == "" || ref == "" {
|
||||||
|
return "", fmt.Errorf("%s_FILE and %s are required", name, refName)
|
||||||
|
}
|
||||||
|
root := os.Getenv("SECRET_BACKEND_ROOT")
|
||||||
|
if root == "" {
|
||||||
|
root = "/run/secrets"
|
||||||
|
}
|
||||||
|
resolver, err := platformsecrets.NewFileResolver(root)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: consumer, Path: path})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/adapters/postgres"
|
||||||
|
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||||
|
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
workerID := flag.String("worker-id", "reflection-worker-1", "stable worker identity")
|
||||||
|
lease := flag.Duration("lease-ttl", 5*time.Minute, "candidate claim lease")
|
||||||
|
pollInterval := flag.Duration("poll-interval", 15*time.Second, "database-owned idle wake-up interval")
|
||||||
|
once := flag.Bool("once", false, "run one cycle and exit")
|
||||||
|
flag.Parse()
|
||||||
|
ctx := context.Background()
|
||||||
|
password, err := resolveDatabasePassword("EPISTEMICS_POSTGRES_PASSWORD", "EPISTEMICS_POSTGRES_CREDENTIAL_REF")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
config := postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("EPISTEMICS_POSTGRES_USER"), Password: password}
|
||||||
|
repository, err := postgres.OpenEpistemics(ctx, config)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer repository.Close()
|
||||||
|
for {
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if _, err := repository.RecoverReflectionClaims(ctx, now); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
candidate, claimErr := repository.ClaimReflectionCandidate(ctx, *workerID, now, lease.Milliseconds())
|
||||||
|
if claimErr == nil {
|
||||||
|
// This worker only closes the durable evaluation loop. Any cognitive
|
||||||
|
// proposal remains a candidate and must be admitted by a separate governed flow.
|
||||||
|
if err := repository.ResolveReflectionCandidate(ctx, candidate.ID, *workerID, time.Now().UnixMilli()); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("resolved reflection candidate %s\n", candidate.ID)
|
||||||
|
} else if *once {
|
||||||
|
fmt.Println("no reflection candidate")
|
||||||
|
}
|
||||||
|
if *once {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := repository.WaitForReflectionCandidate(ctx, *pollInterval); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDatabasePassword(name, refName string) (string, error) {
|
||||||
|
if _, ok := os.LookupEnv(name); ok {
|
||||||
|
return "", fmt.Errorf("%s direct environment secret is forbidden", name)
|
||||||
|
}
|
||||||
|
path, ref := os.Getenv(name+"_FILE"), os.Getenv(refName)
|
||||||
|
if path == "" || ref == "" {
|
||||||
|
return "", fmt.Errorf("%s_FILE and %s are required", name, refName)
|
||||||
|
}
|
||||||
|
root := os.Getenv("SECRET_BACKEND_ROOT")
|
||||||
|
if root == "" {
|
||||||
|
root = "/run/secrets"
|
||||||
|
}
|
||||||
|
resolver, err := platformsecrets.NewFileResolver(root)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: path})
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package capability
|
||||||
|
|
||||||
|
type FleetTier string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FleetTierFast FleetTier = "FAST"
|
||||||
|
FleetTierMedium FleetTier = "MEDIUM"
|
||||||
|
FleetTierHeavy FleetTier = "HEAVY"
|
||||||
|
FleetTierLifeboat FleetTier = "LIFEBOAT"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FleetMember struct {
|
||||||
|
ProviderInstanceID ProviderInstanceID
|
||||||
|
ModelDescriptorID ModelDescriptorID
|
||||||
|
Tier FleetTier
|
||||||
|
Ready bool
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FleetReadiness struct {
|
||||||
|
ObservedAtUnixMillis int64
|
||||||
|
Members []FleetMember
|
||||||
|
ReadyTiers []FleetTier
|
||||||
|
FallbackTier FleetTier
|
||||||
|
}
|
||||||
@@ -16,19 +16,24 @@ type FreshnessState string
|
|||||||
type CoverageState string
|
type CoverageState string
|
||||||
type ConflictState string
|
type ConflictState string
|
||||||
type EvidenceKind string
|
type EvidenceKind string
|
||||||
|
type ResearchFindingState string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
FreshnessCurrent FreshnessState = "CURRENT"
|
FreshnessCurrent FreshnessState = "CURRENT"
|
||||||
FreshnessStale FreshnessState = "STALE"
|
FreshnessStale FreshnessState = "STALE"
|
||||||
FreshnessUnknown FreshnessState = "UNKNOWN"
|
FreshnessUnknown FreshnessState = "UNKNOWN"
|
||||||
CoverageComplete CoverageState = "COMPLETE"
|
CoverageComplete CoverageState = "COMPLETE"
|
||||||
CoveragePartial CoverageState = "PARTIAL"
|
CoveragePartial CoverageState = "PARTIAL"
|
||||||
CoverageUnknown CoverageState = "UNKNOWN"
|
CoverageUnknown CoverageState = "UNKNOWN"
|
||||||
ConflictNone ConflictState = "NONE"
|
ConflictNone ConflictState = "NONE"
|
||||||
ConflictPresent ConflictState = "PRESENT"
|
ConflictPresent ConflictState = "PRESENT"
|
||||||
ConflictUnknown ConflictState = "UNKNOWN"
|
ConflictUnknown ConflictState = "UNKNOWN"
|
||||||
EvidenceObserved EvidenceKind = "OBSERVED"
|
EvidenceObserved EvidenceKind = "OBSERVED"
|
||||||
EvidenceAssumed EvidenceKind = "ASSUMED"
|
EvidenceAssumed EvidenceKind = "ASSUMED"
|
||||||
|
FindingSupported ResearchFindingState = "SUPPORTED"
|
||||||
|
FindingStale ResearchFindingState = "STALE"
|
||||||
|
FindingConflicted ResearchFindingState = "CONFLICTED"
|
||||||
|
FindingUnknown ResearchFindingState = "UNKNOWN"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ResearchPlan struct {
|
type ResearchPlan struct {
|
||||||
@@ -72,6 +77,35 @@ type Citation struct {
|
|||||||
ResolvedAtUnixMillis int64
|
ResolvedAtUnixMillis int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResearchCandidate is read-only evidence returned by a connector. The
|
||||||
|
// connector supplies the immutable snapshot and exact excerpt; the evaluator
|
||||||
|
// never fetches, rewrites or invents either one.
|
||||||
|
type ResearchCandidate struct {
|
||||||
|
PropositionKey string
|
||||||
|
Statement string
|
||||||
|
Source Source
|
||||||
|
Snapshot SourceSnapshot
|
||||||
|
Excerpt SourceExcerpt
|
||||||
|
Evidence EvidenceKind
|
||||||
|
Coverage CoverageState
|
||||||
|
Conflict ConflictState
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResearchFinding struct {
|
||||||
|
PropositionKey string
|
||||||
|
Statement string
|
||||||
|
Claim Claim
|
||||||
|
Citations []Citation
|
||||||
|
State ResearchFindingState
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResearchResult struct {
|
||||||
|
PlanID ResearchPlanID
|
||||||
|
Findings []ResearchFinding
|
||||||
|
EvaluatedAtUnixMillis int64
|
||||||
|
}
|
||||||
|
|
||||||
// ResolveCitation creates evidence only when all identifiers form one exact
|
// ResolveCitation creates evidence only when all identifiers form one exact
|
||||||
// source chain. It deliberately does not fetch, infer or rewrite source text.
|
// source chain. It deliberately does not fetch, infer or rewrite source text.
|
||||||
func ResolveCitation(source Source, snapshot SourceSnapshot, excerpt SourceExcerpt, evidence EvidenceKind, coverage CoverageState, conflict ConflictState, resolvedAtUnixMillis int64) (Citation, error) {
|
func ResolveCitation(source Source, snapshot SourceSnapshot, excerpt SourceExcerpt, evidence EvidenceKind, coverage CoverageState, conflict ConflictState, resolvedAtUnixMillis int64) (Citation, error) {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package epistemics
|
||||||
|
|
||||||
|
type BeliefState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BeliefProvisional BeliefState = "PROVISIONAL"
|
||||||
|
BeliefConflicted BeliefState = "CONFLICTED"
|
||||||
|
BeliefVerified BeliefState = "VERIFIED"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Observation struct {
|
||||||
|
ID ObservationID
|
||||||
|
SourceSnapshot SourceSnapshotID
|
||||||
|
Excerpt SourceExcerptID
|
||||||
|
Text string
|
||||||
|
ObservedAt int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type Claim struct {
|
||||||
|
ID ClaimID
|
||||||
|
Statement string
|
||||||
|
ObservationID ObservationID
|
||||||
|
CitationID CitationID
|
||||||
|
Verification VerificationState
|
||||||
|
}
|
||||||
|
|
||||||
|
type Belief struct {
|
||||||
|
ID BeliefID
|
||||||
|
ClaimID ClaimID
|
||||||
|
State BeliefState
|
||||||
|
}
|
||||||
|
|
||||||
|
type TruthBundle struct {
|
||||||
|
Observation Observation
|
||||||
|
Claim Claim
|
||||||
|
Belief Belief
|
||||||
|
Citation Citation
|
||||||
|
}
|
||||||
@@ -120,15 +120,17 @@ type ExperienceRecord struct {
|
|||||||
// Cognitive reflection, if used, must go through Cognitive Gateway/Router and
|
// Cognitive reflection, if used, must go through Cognitive Gateway/Router and
|
||||||
// produce candidates only; it cannot directly rewrite canonical state.
|
// produce candidates only; it cannot directly rewrite canonical state.
|
||||||
type ReflectionCandidate struct {
|
type ReflectionCandidate struct {
|
||||||
ID ReflectionCandidateID
|
ID ReflectionCandidateID
|
||||||
Trigger ReflectionTriggerKind
|
Trigger ReflectionTriggerKind
|
||||||
State ReflectionCandidateState
|
State ReflectionCandidateState
|
||||||
SourceExperienceID ExperienceRecordID
|
SourceExperienceID ExperienceRecordID
|
||||||
OriginalIntentID OriginalIntentRef
|
OriginalIntentID OriginalIntentRef
|
||||||
SourceUtteranceID SourceUtteranceRef
|
SourceUtteranceID SourceUtteranceRef
|
||||||
Summary string
|
Summary string
|
||||||
EvidenceRefs []EvidenceRef
|
EvidenceRefs []EvidenceRef
|
||||||
RequiresCognition bool
|
RequiresCognition bool
|
||||||
RecordedAtUnixMillis int64
|
RecordedAtUnixMillis int64
|
||||||
Revision int64
|
Revision int64
|
||||||
|
ClaimedBy string
|
||||||
|
ClaimExpiresAtUnixMillis int64
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ const (
|
|||||||
type RuntimeConsumer string
|
type RuntimeConsumer string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RuntimeConsumerCore RuntimeConsumer = "core-runtime"
|
RuntimeConsumerCore RuntimeConsumer = "core-runtime"
|
||||||
|
RuntimeConsumerWorkWorker RuntimeConsumer = "agent-worker"
|
||||||
|
RuntimeConsumerEpistemicsWorker RuntimeConsumer = "reflection-worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SecretFileRef is bootstrap delivery metadata for the V1 protected local
|
// SecretFileRef is bootstrap delivery metadata for the V1 protected local
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package work
|
||||||
|
|
||||||
|
type GraphNodeID string
|
||||||
|
|
||||||
|
type GraphState string
|
||||||
|
type GraphNodeState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
GraphDraft GraphState = "DRAFT"
|
||||||
|
GraphAdmitted GraphState = "ADMITTED"
|
||||||
|
GraphRunning GraphState = "RUNNING"
|
||||||
|
GraphPartial GraphState = "PARTIAL"
|
||||||
|
GraphVerifying GraphState = "VERIFYING"
|
||||||
|
GraphCompleted GraphState = "COMPLETED"
|
||||||
|
GraphFailed GraphState = "FAILED"
|
||||||
|
GraphUnknown GraphState = "UNKNOWN"
|
||||||
|
|
||||||
|
GraphNodeBlocked GraphNodeState = "BLOCKED_PREREQUISITE"
|
||||||
|
GraphNodeReady GraphNodeState = "READY"
|
||||||
|
GraphNodeLeased GraphNodeState = "LEASED"
|
||||||
|
GraphNodeRunning GraphNodeState = "RUNNING"
|
||||||
|
GraphNodeSuccess GraphNodeState = "SUCCEEDED"
|
||||||
|
GraphNodeFailed GraphNodeState = "FAILED"
|
||||||
|
GraphNodeCancel GraphNodeState = "CANCELLED"
|
||||||
|
GraphNodeUnknown GraphNodeState = "UNKNOWN"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GraphBudget struct {
|
||||||
|
MaxParallel int
|
||||||
|
MaxAttempts int
|
||||||
|
}
|
||||||
|
|
||||||
|
type GraphNode struct {
|
||||||
|
ID GraphNodeID
|
||||||
|
IdempotencyKey string
|
||||||
|
State GraphNodeState
|
||||||
|
Attempts int
|
||||||
|
}
|
||||||
|
|
||||||
|
type GraphEdge struct {
|
||||||
|
From GraphNodeID
|
||||||
|
To GraphNodeID
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkGraph struct {
|
||||||
|
ID string
|
||||||
|
Revision int64
|
||||||
|
State GraphState
|
||||||
|
Nodes []GraphNode
|
||||||
|
Edges []GraphEdge
|
||||||
|
Budget GraphBudget
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResultCandidate struct {
|
||||||
|
ID string
|
||||||
|
GraphID string
|
||||||
|
GraphRevision int64
|
||||||
|
NodeID GraphNodeID
|
||||||
|
AuthorRunID AgentRunID
|
||||||
|
ArtifactRefs []EvidenceRef
|
||||||
|
EvidenceSummary string
|
||||||
|
Summary string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConflictSet struct {
|
||||||
|
ID string
|
||||||
|
GraphID string
|
||||||
|
GraphRevision int64
|
||||||
|
NodeID GraphNodeID
|
||||||
|
CandidateIDs []string
|
||||||
|
Resolved bool
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package work
|
||||||
|
|
||||||
|
type DevelopmentStage string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DevelopmentEvidenceCollected DevelopmentStage = "EVIDENCE_COLLECTED"
|
||||||
|
DevelopmentProposalCreated DevelopmentStage = "PROPOSAL_CREATED"
|
||||||
|
DevelopmentTaskAdmitted DevelopmentStage = "TASK_ADMITTED"
|
||||||
|
DevelopmentIsolated DevelopmentStage = "ISOLATED"
|
||||||
|
DevelopmentVerified DevelopmentStage = "VERIFIED"
|
||||||
|
DevelopmentCanary DevelopmentStage = "CANARY"
|
||||||
|
DevelopmentPromoted DevelopmentStage = "PROMOTED"
|
||||||
|
DevelopmentRolledBack DevelopmentStage = "ROLLED_BACK"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DevelopmentGate struct {
|
||||||
|
ID string
|
||||||
|
Stage DevelopmentStage
|
||||||
|
EvidenceRefs []string
|
||||||
|
ProposalRef string
|
||||||
|
TaskPacketRef string
|
||||||
|
WorktreeRef string
|
||||||
|
ProducerRunRefs []AgentRunID
|
||||||
|
VerifierRunRefs []AgentRunID
|
||||||
|
VerificationEvidence string
|
||||||
|
CanaryEvidence string
|
||||||
|
PromotionRef string
|
||||||
|
RollbackRef string
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SET ROLE work_role;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS work.graphs (
|
||||||
|
graph_id TEXT NOT NULL,
|
||||||
|
revision BIGINT NOT NULL,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
max_parallel INTEGER NOT NULL,
|
||||||
|
max_attempts INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (graph_id, revision),
|
||||||
|
CHECK (graph_id <> ''), CHECK (revision > 0),
|
||||||
|
CHECK (state IN ('DRAFT','ADMITTED','RUNNING','PAUSED','CANCELLING','PARTIAL','VERIFYING','COMPLETED','FAILED','UNKNOWN','RECOVERING')),
|
||||||
|
CHECK (max_parallel > 0), CHECK (max_attempts > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS work.graph_nodes (
|
||||||
|
graph_id TEXT NOT NULL,
|
||||||
|
revision BIGINT NOT NULL,
|
||||||
|
node_id TEXT NOT NULL,
|
||||||
|
idempotency_key TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
attempts INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (graph_id, revision, node_id),
|
||||||
|
FOREIGN KEY (graph_id, revision) REFERENCES work.graphs(graph_id, revision),
|
||||||
|
CHECK (node_id <> ''), CHECK (idempotency_key <> ''), CHECK (attempts >= 0),
|
||||||
|
CHECK (state IN ('BLOCKED_PREREQUISITE','READY','LEASED','RUNNING','SUCCEEDED','FAILED','CANCELLED','UNKNOWN','RECOVERING'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS work.graph_edges (
|
||||||
|
graph_id TEXT NOT NULL,
|
||||||
|
revision BIGINT NOT NULL,
|
||||||
|
from_node_id TEXT NOT NULL,
|
||||||
|
to_node_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (graph_id, revision, from_node_id, to_node_id),
|
||||||
|
FOREIGN KEY (graph_id, revision) REFERENCES work.graphs(graph_id, revision),
|
||||||
|
CHECK (from_node_id <> ''), CHECK (to_node_id <> ''), CHECK (from_node_id <> to_node_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
RESET ROLE;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SET ROLE epistemics_role;
|
||||||
|
|
||||||
|
ALTER TABLE epistemics.reflection_candidates
|
||||||
|
ADD COLUMN IF NOT EXISTS claimed_by TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS claim_expires_at_unix_millis BIGINT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
ALTER TABLE epistemics.reflection_candidates
|
||||||
|
DROP CONSTRAINT IF EXISTS reflection_candidates_claim_expiry_check;
|
||||||
|
ALTER TABLE epistemics.reflection_candidates
|
||||||
|
ADD CONSTRAINT reflection_candidates_claim_expiry_check
|
||||||
|
CHECK (claim_expires_at_unix_millis >= 0);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS reflection_candidates_claimable_idx
|
||||||
|
ON epistemics.reflection_candidates (state, claim_expires_at_unix_millis, recorded_at_unix_millis, reflection_candidate_id);
|
||||||
|
|
||||||
|
RESET ROLE;
|
||||||
|
COMMIT;
|
||||||
@@ -97,6 +97,38 @@ services:
|
|||||||
WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD}
|
WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD}
|
||||||
depends_on: { postgres: { condition: service_healthy } }
|
depends_on: { postgres: { condition: service_healthy } }
|
||||||
|
|
||||||
|
agent-worker:
|
||||||
|
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: agent-worker, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["-worker-id", "agent-worker-production"]
|
||||||
|
environment:
|
||||||
|
POSTGRES_HOST: postgres
|
||||||
|
POSTGRES_PORT: "5432"
|
||||||
|
POSTGRES_DATABASE: ${POSTGRES_DB:-assistant}
|
||||||
|
WORK_POSTGRES_USER: work_role
|
||||||
|
WORK_POSTGRES_CREDENTIAL_REF: credential://domovoy/work/db-password/v1
|
||||||
|
WORK_POSTGRES_PASSWORD_FILE: /run/secrets/work_db_password
|
||||||
|
SECRET_BACKEND_ROOT: /run/secrets
|
||||||
|
depends_on: { postgres: { condition: service_healthy } }
|
||||||
|
volumes:
|
||||||
|
- assistant_core_secrets:/run/secrets:ro
|
||||||
|
|
||||||
|
reflection-worker:
|
||||||
|
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: reflection-worker, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["-worker-id", "reflection-worker-production"]
|
||||||
|
environment:
|
||||||
|
POSTGRES_HOST: postgres
|
||||||
|
POSTGRES_PORT: "5432"
|
||||||
|
POSTGRES_DATABASE: ${POSTGRES_DB:-assistant}
|
||||||
|
EPISTEMICS_POSTGRES_USER: epistemics_role
|
||||||
|
EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1
|
||||||
|
EPISTEMICS_POSTGRES_PASSWORD_FILE: /run/secrets/epistemics_db_password
|
||||||
|
SECRET_BACKEND_ROOT: /run/secrets
|
||||||
|
depends_on: { postgres: { condition: service_healthy } }
|
||||||
|
volumes:
|
||||||
|
- assistant_core_secrets:/run/secrets:ro
|
||||||
|
|
||||||
continuity-runtime:
|
continuity-runtime:
|
||||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: continuity-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: continuity-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"task_file": "dev/tasks/DEV-000130-agent-worker-recovery.json",
|
"task_file": "dev/tasks/DEV-000140-research-evaluation-runtime.json",
|
||||||
"task_id": "DEV-000130",
|
"task_id": "DEV-000140",
|
||||||
"task_revision": 1
|
"task_revision": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"task_id": "DEV-000131",
|
||||||
|
"task_revision": 1,
|
||||||
|
"architecture_revision": "ADR-0018",
|
||||||
|
"base_repo_revision": "a445034",
|
||||||
|
"risk_tier": "E",
|
||||||
|
"intent": "Implement the first deterministic Multi-Agent Work Graph harness: typed graph revision, DAG validation, budgets and dependency-aware ready-node selection.",
|
||||||
|
"requirement_ids": ["WP-400", "DAG validation", "Fan-out/fan-in foundation"],
|
||||||
|
"allowed_change_scope": {"modules": ["work-agent-execution"], "paths": ["contracts/work/graph.go", "modules/work/domain/graph.go", "tests/agent_execution/graph_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000131-work-graph-harness.json"]},
|
||||||
|
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "database/**", "adapters/**", "cmd/**", "runtime/**", ".gitea/**"]},
|
||||||
|
"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": ["ADR-0021 graph proposal", "DEV-000130 worker/recovery contracts"], "may_change": ["Work graph typed contracts and pure domain harness"]},
|
||||||
|
"semantic_owner": "work-agent-execution",
|
||||||
|
"canonical_state": ["Typed WorkGraph value only; no persistence in this harness"],
|
||||||
|
"state_transitions": ["DRAFT/ADMITTED/RUNNING graph with dependency-aware READY nodes"],
|
||||||
|
"side_effects_and_privileges": ["Pure deterministic validation; no dispatch or external mutation"],
|
||||||
|
"persistence_and_migrations": ["Persistence/scheduler implementation follows after harness"],
|
||||||
|
"failure_retry_restart": ["Cycles, duplicate IDs/keys and invalid budgets reject before scheduling"],
|
||||||
|
"assumptions": ["Graph scheduler must not bypass Agent Execution leases or verifier policy"],
|
||||||
|
"acceptance_scenarios": ["Cycle rejected", "Ready nodes deterministic", "Dependent node remains blocked until prerequisite succeeds", "Budget is validated"],
|
||||||
|
"required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"],
|
||||||
|
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"task_id": "DEV-000132",
|
||||||
|
"task_revision": 1,
|
||||||
|
"architecture_revision": "ADR-0018",
|
||||||
|
"base_repo_revision": "b2f922c",
|
||||||
|
"risk_tier": "E",
|
||||||
|
"intent": "Add deterministic Work Graph admission, bounded ready-node scheduling and explicit conflict resolution to the graph harness.",
|
||||||
|
"requirement_ids": ["WP-400", "Graph admission", "Bounded scheduler", "Conflict resolution"],
|
||||||
|
"allowed_change_scope": {"modules": ["work-agent-execution"], "paths": ["modules/work/domain/graph_scheduler.go", "tests/agent_execution/graph_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000132-work-graph-admission-scheduler.json"]},
|
||||||
|
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "database/**", "adapters/**", "cmd/**", "runtime/**", ".gitea/**"]},
|
||||||
|
"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": ["DEV-000131 graph harness", "ADR-0021 graph lifecycle"], "may_change": ["Pure graph admission/scheduling/conflict domain functions"]},
|
||||||
|
"semantic_owner": "work-agent-execution",
|
||||||
|
"canonical_state": ["No persistence; graph values remain deterministic harness state"],
|
||||||
|
"state_transitions": ["DRAFT -> ADMITTED; READY nodes bounded by MaxParallel; conflict unresolved -> explicitly resolved"],
|
||||||
|
"side_effects_and_privileges": ["Pure domain logic only"],
|
||||||
|
"persistence_and_migrations": ["Durable scheduler follows after the harness"],
|
||||||
|
"failure_retry_restart": ["Budget overflow and unknown conflict candidate reject without mutation"],
|
||||||
|
"assumptions": ["Runtime scheduler must use Agent Execution queue/leases in a later task"],
|
||||||
|
"acceptance_scenarios": ["Admission validates attempt budget", "Scheduler respects active/max parallel", "Conflict cannot resolve without explicit known candidate"],
|
||||||
|
"required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"],
|
||||||
|
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"task_id": "DEV-000133",
|
||||||
|
"task_revision": 1,
|
||||||
|
"architecture_revision": "ADR-0018",
|
||||||
|
"base_repo_revision": "4a800df",
|
||||||
|
"risk_tier": "E",
|
||||||
|
"intent": "Persist immutable Work Graph revisions with typed nodes and dependency edges for durable scheduler integration.",
|
||||||
|
"requirement_ids": ["WP-401", "Durable graph revision", "Graph node dependencies"],
|
||||||
|
"allowed_change_scope": {"modules": ["work-agent-execution", "work-storage"], "paths": ["database/migrations/031_work_graph_persistence.sql", "adapters/postgres/work_repository.go", "tests/agent_execution/graph_persistence_sql_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000133-work-graph-persistence.json"]},
|
||||||
|
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/**", "runtime/**", ".gitea/**"]},
|
||||||
|
"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": ["DEV-000131/132 graph contracts and scheduler"], "may_change": ["Work graph persistence schema and repository methods"]},
|
||||||
|
"semantic_owner": "work-agent-execution",
|
||||||
|
"canonical_state": ["work.graphs", "work.graph_nodes", "work.graph_edges"],
|
||||||
|
"state_transitions": ["graph revision persisted once; nodes and edges immutable within revision"],
|
||||||
|
"side_effects_and_privileges": ["Work schema only; no cross-owner SQL"],
|
||||||
|
"persistence_and_migrations": ["Append-only migration 031; production apply is later canary"],
|
||||||
|
"failure_retry_restart": ["Idempotent inserts; load reconstructs deterministic node/edge order"],
|
||||||
|
"assumptions": ["Durable scheduler and result/conflict persistence follow"],
|
||||||
|
"acceptance_scenarios": ["Graph revision cannot collide into mutation", "Nodes/edges remain tied to revision", "Load reconstructs graph"],
|
||||||
|
"required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"],
|
||||||
|
"terminal_gap_states": ["BLOCKED_BY_MIGRATION_DECISION", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"task_id": "DEV-000134",
|
||||||
|
"task_revision": 1,
|
||||||
|
"architecture_revision": "ADR-0018",
|
||||||
|
"base_repo_revision": "e9bb42c",
|
||||||
|
"risk_tier": "E",
|
||||||
|
"intent": "Implement the first Truth runtime compiler that produces typed Observation, Claim, Belief and Citation values from an exact source chain.",
|
||||||
|
"requirement_ids": ["WP-100", "Observation/Claim/Belief", "Truth citation provenance"],
|
||||||
|
"allowed_change_scope": {"modules": ["epistemics"], "paths": ["contracts/epistemics/truth.go", "modules/epistemics/domain/truth.go", "tests/research_contracts/truth_bundle_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000134-truth-bundle-compiler.json"]},
|
||||||
|
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "database/**", "adapters/**", "cmd/**", "runtime/**", ".gitea/**"]},
|
||||||
|
"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": ["DEV-000109 research contracts", "DEV-000110 citation resolver"], "may_change": ["Typed Truth bundle contracts and pure compiler"]},
|
||||||
|
"semantic_owner": "epistemics",
|
||||||
|
"canonical_state": ["Typed TruthBundle value only; persistence follows"],
|
||||||
|
"state_transitions": ["source chain -> observed observation -> unverified claim -> provisional/conflicted belief"],
|
||||||
|
"side_effects_and_privileges": ["Pure compiler; no fetching, external mutation or authority grant"],
|
||||||
|
"persistence_and_migrations": ["Truth persistence is a later task"],
|
||||||
|
"failure_retry_restart": ["Broken source chain rejects; conflict remains visible"],
|
||||||
|
"assumptions": ["Research executor will supply exact source snapshots/excerpts"],
|
||||||
|
"acceptance_scenarios": ["Observation/claim/citation refs align", "Conflict produces CONFLICTED belief", "Broken source chain is rejected"],
|
||||||
|
"required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"],
|
||||||
|
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"task_id": "DEV-000135",
|
||||||
|
"task_revision": 1,
|
||||||
|
"architecture_revision": "ADR-0018",
|
||||||
|
"base_repo_revision": "32e33e3",
|
||||||
|
"risk_tier": "E",
|
||||||
|
"intent": "Add durable lease, stale recovery, and one-shot governed closure for reflection candidates.",
|
||||||
|
"requirement_ids": ["WP-501", "Reflection candidate worker", "Stale claim recovery"],
|
||||||
|
"allowed_change_scope": {"modules": ["epistemics", "epistemics-storage"], "paths": ["contracts/epistemics/types.go", "database/migrations/032_reflection_candidate_leases.sql", "adapters/postgres/epistemics_repository.go", "cmd/reflection-worker/main.go", "tests/research_contracts/reflection_worker_sql_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000135-reflection-worker.json"]},
|
||||||
|
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "contracts/authority/**", "modules/cognitive/**", ".gitea/**"]},
|
||||||
|
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 1},
|
||||||
|
"contracts": {"consume": ["Existing epistemics ReflectionCandidate contract"], "may_change": ["ReflectionCandidate lease metadata"]},
|
||||||
|
"semantic_owner": "epistemics",
|
||||||
|
"canonical_state": ["epistemics.reflection_candidates"],
|
||||||
|
"state_transitions": ["OPEN to EVALUATING under lease; EVALUATING to OPEN on expiry; EVALUATING to RESOLVED by owning worker"],
|
||||||
|
"side_effects_and_privileges": ["Epistemics schema only; no cognitive call and no proposal promotion"],
|
||||||
|
"persistence_and_migrations": ["Append-only migration 032; production apply is later canary"],
|
||||||
|
"failure_retry_restart": ["FOR UPDATE SKIP LOCKED claim; expired claims are recoverable; one-shot worker is restart-safe"],
|
||||||
|
"assumptions": ["Cognitive evaluation and skill admission remain separate governed stages"],
|
||||||
|
"acceptance_scenarios": ["Concurrent workers claim distinct candidates", "expired evaluation returns to OPEN", "only claim owner can resolve"],
|
||||||
|
"required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"],
|
||||||
|
"terminal_gap_states": ["BLOCKED_BY_MIGRATION_DECISION", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"task_id": "DEV-000136",
|
||||||
|
"task_revision": 1,
|
||||||
|
"architecture_revision": "ADR-0018",
|
||||||
|
"base_repo_revision": "f73c699",
|
||||||
|
"risk_tier": "E",
|
||||||
|
"intent": "Make model fleet tiers, readiness, and explicit lifeboat fallback durable as typed capability policy.",
|
||||||
|
"requirement_ids": ["WP-601", "Model fleet policy", "Fallback readiness"],
|
||||||
|
"allowed_change_scope": {"modules": ["capability"], "paths": ["contracts/capability/fleet.go", "modules/capability/domain/fleet.go", "modules/capability/domain/fleet_test.go", "tests/cognitive_registry/fleet_contract_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000136-fleet-readiness-policy.json"]},
|
||||||
|
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "database/**", "cmd/**", ".gitea/**"]},
|
||||||
|
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||||
|
"contracts": {"consume": ["Existing cognitive registry and health observations"], "may_change": ["Typed fleet readiness policy"]},
|
||||||
|
"semantic_owner": "capability",
|
||||||
|
"canonical_state": ["FleetReadiness value object"],
|
||||||
|
"state_transitions": ["observed member readiness determines selected preferred or fallback tier"],
|
||||||
|
"side_effects_and_privileges": ["No provider invocation or external mutation"],
|
||||||
|
"persistence_and_migrations": ["In-memory typed decision; runtime persistence follows fleet registration integration"],
|
||||||
|
"failure_retry_restart": ["No ready fallback yields explicit error"],
|
||||||
|
"assumptions": ["Existing Cognitive Gateway remains the only invocation boundary"],
|
||||||
|
"acceptance_scenarios": ["heavy timeout selects ready lifeboat", "no ready member fails closed"],
|
||||||
|
"required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"],
|
||||||
|
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"task_id": "DEV-000137",
|
||||||
|
"task_revision": 1,
|
||||||
|
"architecture_revision": "ADR-0018",
|
||||||
|
"base_repo_revision": "d38406f",
|
||||||
|
"risk_tier": "E",
|
||||||
|
"intent": "Add typed governed self-development gate requiring evidence, task packet, isolated worktree, independent verifier, canary, promotion or rollback.",
|
||||||
|
"requirement_ids": ["WP-701", "Governed self-development", "Independent verification", "Rollback"],
|
||||||
|
"allowed_change_scope": {"modules": ["work-agent-execution"], "paths": ["contracts/work/self_development.go", "modules/work/domain/self_development.go", "modules/work/domain/self_development_test.go", "tests/semantic_evolution/self_development_contract_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000137-governed-self-development-gate.json"]},
|
||||||
|
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "database/**", "cmd/**", "contracts/authority/**", ".gitea/**"]},
|
||||||
|
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||||
|
"contracts": {"consume": ["Existing AgentRun and work graph contracts"], "may_change": ["Typed self-development stage gate"]},
|
||||||
|
"semantic_owner": "work-agent-execution",
|
||||||
|
"canonical_state": ["DevelopmentGate value object"],
|
||||||
|
"state_transitions": ["evidence to proposal to task to isolation to independent verification to canary to promotion; canary may rollback"],
|
||||||
|
"side_effects_and_privileges": ["No direct code mutation, provider invocation, or promotion side effect"],
|
||||||
|
"persistence_and_migrations": ["Value object only; durable gate persistence follows execution-plane integration"],
|
||||||
|
"failure_retry_restart": ["Invalid stage transitions and missing evidence fail closed"],
|
||||||
|
"assumptions": ["Task packet and worktree identity are provided by the execution plane"],
|
||||||
|
"acceptance_scenarios": ["same producer cannot verify output", "promotion is terminal", "canary can rollback"],
|
||||||
|
"required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"],
|
||||||
|
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"task_id": "DEV-000138",
|
||||||
|
"task_revision": 1,
|
||||||
|
"architecture_revision": "ADR-0018",
|
||||||
|
"base_repo_revision": "1ccd43c",
|
||||||
|
"risk_tier": "E",
|
||||||
|
"intent": "Add executable contract coverage for authenticated cabinet, operations, learning, accessibility, responsive, and reduced-motion surfaces.",
|
||||||
|
"requirement_ids": ["WP-801", "WP-802", "WP-805", "Authenticated cabinet acceptance"],
|
||||||
|
"allowed_change_scope": {"modules": ["creator-web"], "paths": ["tests/creator_web/cabinet_acceptance_contract_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000138-ui-operations-acceptance-contract.json"]},
|
||||||
|
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "database/**", "cmd/**", "contracts/**", ".gitea/**"]},
|
||||||
|
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||||
|
"contracts": {"consume": ["Existing creator-web authenticated API and static cabinet"], "may_change": ["Acceptance tests only"]},
|
||||||
|
"semantic_owner": "creator-web",
|
||||||
|
"canonical_state": ["None; tests inspect existing UI contract"],
|
||||||
|
"state_transitions": ["login session gates cabinet; operations and learning request authenticated API data"],
|
||||||
|
"side_effects_and_privileges": ["No external mutation; live authenticated browser acceptance remains a separate production verification"],
|
||||||
|
"persistence_and_migrations": ["None"],
|
||||||
|
"failure_retry_restart": ["Missing auth, learning, or responsive contract fails CI"],
|
||||||
|
"assumptions": ["Credentials are never embedded in acceptance tests"],
|
||||||
|
"acceptance_scenarios": ["cabinet exposes operations and reflection surfaces", "session and learning routes are authenticated", "mobile/reduced motion rules are present"],
|
||||||
|
"required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"],
|
||||||
|
"terminal_gap_states": ["BLOCKED_BY_AUTHENTICATED_BROWSER_SESSION", "BLOCKED_BY_CONFLICT"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"task_id": "DEV-000139",
|
||||||
|
"task_revision": 2,
|
||||||
|
"architecture_revision": "ADR-0018",
|
||||||
|
"base_repo_revision": "6f5f7e1",
|
||||||
|
"risk_tier": "E",
|
||||||
|
"intent": "Run agent and reflection workers as restartable production processes with DB-owned idle wakeups and protected credential refs.",
|
||||||
|
"requirement_ids": ["WP-302", "WP-502", "Automatic worker runtime", "Production recovery"],
|
||||||
|
"allowed_change_scope": {"modules": ["work-agent-execution", "epistemics", "deployment", "creator-web-acceptance"], "paths": ["contracts/secrets/types.go", "modules/work/application/worker.go", "adapters/postgres/work_repository.go", "cmd/agent-worker/main.go", "adapters/postgres/epistemics_repository.go", "cmd/reflection-worker/main.go", "deploy/docker-compose.prod.yml", "tests/creator_web/verify-remote.ps1", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000139-production-worker-runtime.json"]},
|
||||||
|
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "contracts/authority/**", ".gitea/**"]},
|
||||||
|
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 2, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||||
|
"contracts": {"consume": ["Existing durable dispatch/reflection tables and file secret resolver"], "may_change": ["Worker process lifecycle and production service wiring"]},
|
||||||
|
"semantic_owner": "work-agent-execution and epistemics",
|
||||||
|
"canonical_state": ["work.agent_dispatch_queue", "epistemics.reflection_candidates"],
|
||||||
|
"state_transitions": ["workers recover stale claims, claim durable work, process one cycle, and repeat after DB-owned wakeup"],
|
||||||
|
"side_effects_and_privileges": ["Work and epistemics database roles only; credentials remain CredentialRef/file-backed"],
|
||||||
|
"persistence_and_migrations": ["Uses migrations 030 and 032 already in main"],
|
||||||
|
"failure_retry_restart": ["restartable containers; stale claims recover after restart; -once remains diagnostic mode"],
|
||||||
|
"assumptions": ["Production secret volume contains work and epistemics refs at declared paths"],
|
||||||
|
"acceptance_scenarios": ["both workers stay running idle", "worker survives empty queue", "worker uses protected secret files", "stale dispatch/reflection claims recover"],
|
||||||
|
"required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "docker compose config"],
|
||||||
|
"terminal_gap_states": ["BLOCKED_BY_SECRET_REF_MISMATCH", "BLOCKED_BY_MIGRATION_DECISION", "BLOCKED_BY_CONFLICT"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"task_id": "DEV-000140",
|
||||||
|
"task_revision": 1,
|
||||||
|
"architecture_revision": "ADR-0018",
|
||||||
|
"base_repo_revision": "07a8de9",
|
||||||
|
"risk_tier": "E",
|
||||||
|
"intent": "Evaluate read-only research evidence into explicit supported, stale, conflicted or unknown findings without persistence or authority mutation.",
|
||||||
|
"requirement_ids": ["WP-102", "research evaluation", "citation provenance", "freshness", "conflict visibility"],
|
||||||
|
"allowed_change_scope": {"modules": ["epistemics-research-runtime"], "paths": ["contracts/epistemics/research.go", "modules/epistemics/domain/research.go", "modules/epistemics/application/research.go", "tests/research_runtime/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000140-research-evaluation-runtime.json"]},
|
||||||
|
"forbidden_change_scope": {"paths": ["architecture/**", "database/**", "deploy/**", "security/**", "cmd/**", ".gitea/**"]},
|
||||||
|
"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": ["ADR-0019 typed source, snapshot, excerpt and citation contracts"], "may_change": ["typed research finding and read-only connector boundary"]},
|
||||||
|
"semantic_owner": "epistemics",
|
||||||
|
"canonical_state": [],
|
||||||
|
"state_transitions": ["connector evidence -> supported/stale/conflicted/unknown result", "invalid source chain -> rejected"],
|
||||||
|
"side_effects_and_privileges": ["read-only connector boundary; no external mutation; no claim persistence"],
|
||||||
|
"persistence_and_migrations": ["none"],
|
||||||
|
"failure_retry_restart": ["evaluation is pure and repeatable; connector failures remain errors"],
|
||||||
|
"assumptions": ["connectors provide immutable exact excerpts and source snapshots", "proposition keys are connector-supplied typed grouping keys"],
|
||||||
|
"acceptance_scenarios": ["no evidence remains UNKNOWN", "stale evidence is marked STALE", "incompatible statements remain CONFLICTED", "invalid citation chain is rejected", "connector boundary cannot mutate canonical state"],
|
||||||
|
"required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "research runtime tests"],
|
||||||
|
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_CONFLICT"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/capability"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidFleetReadiness = errors.New("invalid fleet readiness")
|
||||||
|
|
||||||
|
func BuildFleetReadiness(observedAt int64, members []capability.FleetMember) (capability.FleetReadiness, error) {
|
||||||
|
if observedAt < 1 || len(members) == 0 {
|
||||||
|
return capability.FleetReadiness{}, ErrInvalidFleetReadiness
|
||||||
|
}
|
||||||
|
result := capability.FleetReadiness{ObservedAtUnixMillis: observedAt, Members: append([]capability.FleetMember(nil), members...), FallbackTier: capability.FleetTierLifeboat}
|
||||||
|
seen := map[capability.FleetTier]bool{}
|
||||||
|
for i := range result.Members {
|
||||||
|
member := &result.Members[i]
|
||||||
|
if member.ProviderInstanceID == "" || member.ModelDescriptorID == "" || !validFleetTier(member.Tier) || seen[member.Tier] && member.Ready {
|
||||||
|
return capability.FleetReadiness{}, ErrInvalidFleetReadiness
|
||||||
|
}
|
||||||
|
if member.Ready {
|
||||||
|
seen[member.Tier] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tier := range []capability.FleetTier{capability.FleetTierFast, capability.FleetTierMedium, capability.FleetTierHeavy, capability.FleetTierLifeboat} {
|
||||||
|
if seen[tier] {
|
||||||
|
result.ReadyTiers = append(result.ReadyTiers, tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SelectFleetTier(readiness capability.FleetReadiness, preferred capability.FleetTier) (capability.FleetTier, error) {
|
||||||
|
if readiness.ObservedAtUnixMillis < 1 || len(readiness.Members) == 0 || !validFleetTier(readiness.FallbackTier) {
|
||||||
|
return "", ErrInvalidFleetReadiness
|
||||||
|
}
|
||||||
|
if slices.Contains(readiness.ReadyTiers, preferred) {
|
||||||
|
return preferred, nil
|
||||||
|
}
|
||||||
|
if slices.Contains(readiness.ReadyTiers, readiness.FallbackTier) {
|
||||||
|
return readiness.FallbackTier, nil
|
||||||
|
}
|
||||||
|
return "", ErrInvalidFleetReadiness
|
||||||
|
}
|
||||||
|
|
||||||
|
func validFleetTier(value capability.FleetTier) bool {
|
||||||
|
return value == capability.FleetTierFast || value == capability.FleetTierMedium || value == capability.FleetTierHeavy || value == capability.FleetTierLifeboat
|
||||||
|
}
|
||||||
|
|
||||||
|
func SortFleetMembers(members []capability.FleetMember) {
|
||||||
|
sort.SliceStable(members, func(i, j int) bool {
|
||||||
|
if members[i].Tier != members[j].Tier {
|
||||||
|
return members[i].Tier < members[j].Tier
|
||||||
|
}
|
||||||
|
return members[i].ProviderInstanceID < members[j].ProviderInstanceID
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/capability"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFleetReadinessFallsBackToLifeboatWhenPrimaryTiersUnavailable(t *testing.T) {
|
||||||
|
readiness, err := BuildFleetReadiness(100, []capability.FleetMember{{ProviderInstanceID: "cpu", ModelDescriptorID: "qwen", Tier: capability.FleetTierLifeboat, Ready: true, Reason: "local"}, {ProviderInstanceID: "gpu", ModelDescriptorID: "heavy", Tier: capability.FleetTierHeavy, Ready: false, Reason: "timeout"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
selected, err := SelectFleetTier(readiness, capability.FleetTierHeavy)
|
||||||
|
if err != nil || selected != capability.FleetTierLifeboat {
|
||||||
|
t.Fatalf("selected=%q err=%v", selected, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||||
|
domain "git.cin.su/m/Assistant/modules/epistemics/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReadOnlyConnector is the only integration point for web/repository sources.
|
||||||
|
// Implementations return immutable evidence; they cannot persist claims.
|
||||||
|
type ReadOnlyConnector interface {
|
||||||
|
Collect(context.Context, epistemics.ResearchPlan) ([]epistemics.ResearchCandidate, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResearchService struct{ Connector ReadOnlyConnector }
|
||||||
|
|
||||||
|
func (s ResearchService) Evaluate(ctx context.Context, plan epistemics.ResearchPlan, nowUnixMillis int64) (epistemics.ResearchResult, error) {
|
||||||
|
if s.Connector == nil {
|
||||||
|
return epistemics.ResearchResult{}, domain.ErrNoResearchEvidence
|
||||||
|
}
|
||||||
|
candidates, err := s.Connector.Collect(ctx, plan)
|
||||||
|
if err != nil {
|
||||||
|
return epistemics.ResearchResult{}, err
|
||||||
|
}
|
||||||
|
return domain.EvaluateResearch(plan, candidates, nowUnixMillis)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidResearchPlan = errors.New("invalid research plan")
|
||||||
|
ErrInvalidResearchCandidate = errors.New("invalid research candidate")
|
||||||
|
ErrNoResearchEvidence = errors.New("no research evidence")
|
||||||
|
)
|
||||||
|
|
||||||
|
// EvaluateResearch turns connector evidence into an explainable, non-canonical
|
||||||
|
// result. It is deliberately pure: it does not call a provider, persist a
|
||||||
|
// claim, or promote an assessment into authority.
|
||||||
|
func EvaluateResearch(plan epistemics.ResearchPlan, candidates []epistemics.ResearchCandidate, evaluatedAtUnixMillis int64) (epistemics.ResearchResult, error) {
|
||||||
|
if err := plan.Validate(); err != nil || evaluatedAtUnixMillis < 1 {
|
||||||
|
return epistemics.ResearchResult{}, ErrInvalidResearchPlan
|
||||||
|
}
|
||||||
|
result := epistemics.ResearchResult{PlanID: plan.ID, EvaluatedAtUnixMillis: evaluatedAtUnixMillis}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
result.Findings = []epistemics.ResearchFinding{{State: epistemics.FindingUnknown, Reason: "no connector evidence was returned"}}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := make(map[string][]epistemics.ResearchCandidate)
|
||||||
|
order := make([]string, 0, len(candidates))
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if strings.TrimSpace(candidate.PropositionKey) == "" || strings.TrimSpace(candidate.Statement) == "" || candidate.Evidence == "" {
|
||||||
|
return epistemics.ResearchResult{}, ErrInvalidResearchCandidate
|
||||||
|
}
|
||||||
|
if _, exists := groups[candidate.PropositionKey]; !exists {
|
||||||
|
order = append(order, candidate.PropositionKey)
|
||||||
|
}
|
||||||
|
groups[candidate.PropositionKey] = append(groups[candidate.PropositionKey], candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range order {
|
||||||
|
finding, err := evaluateGroup(key, groups[key], evaluatedAtUnixMillis)
|
||||||
|
if err != nil {
|
||||||
|
return epistemics.ResearchResult{}, err
|
||||||
|
}
|
||||||
|
result.Findings = append(result.Findings, finding)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateGroup(key string, candidates []epistemics.ResearchCandidate, evaluatedAt int64) (epistemics.ResearchFinding, error) {
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return epistemics.ResearchFinding{}, ErrNoResearchEvidence
|
||||||
|
}
|
||||||
|
first := candidates[0]
|
||||||
|
firstBundle, err := compileResearchCandidate(first)
|
||||||
|
if err != nil {
|
||||||
|
return epistemics.ResearchFinding{}, ErrInvalidResearchCandidate
|
||||||
|
}
|
||||||
|
finding := epistemics.ResearchFinding{PropositionKey: key, Statement: first.Statement, Claim: firstBundle.Claim, Citations: []epistemics.Citation{firstBundle.Citation}, State: epistemics.FindingSupported}
|
||||||
|
statements := map[string]struct{}{normalize(first.Statement): {}}
|
||||||
|
if first.Snapshot.Freshness == epistemics.FreshnessUnknown || first.Evidence == epistemics.EvidenceAssumed || first.Coverage != epistemics.CoverageComplete {
|
||||||
|
finding.State, finding.Reason = epistemics.FindingUnknown, "evidence is assumed, unknown or incomplete"
|
||||||
|
} else if first.Snapshot.Freshness == epistemics.FreshnessStale {
|
||||||
|
finding.State, finding.Reason = epistemics.FindingStale, "source snapshot is stale"
|
||||||
|
}
|
||||||
|
for _, candidate := range candidates[1:] {
|
||||||
|
bundle, resolveErr := compileResearchCandidate(candidate)
|
||||||
|
if resolveErr != nil {
|
||||||
|
return epistemics.ResearchFinding{}, ErrInvalidResearchCandidate
|
||||||
|
}
|
||||||
|
finding.Citations = append(finding.Citations, bundle.Citation)
|
||||||
|
statements[normalize(candidate.Statement)] = struct{}{}
|
||||||
|
if candidate.Snapshot.Freshness == epistemics.FreshnessUnknown || candidate.Evidence == epistemics.EvidenceAssumed || candidate.Coverage != epistemics.CoverageComplete {
|
||||||
|
finding.State, finding.Reason = epistemics.FindingUnknown, "at least one source has assumed, unknown or incomplete evidence"
|
||||||
|
} else if candidate.Snapshot.Freshness == epistemics.FreshnessStale {
|
||||||
|
finding.State, finding.Reason = epistemics.FindingStale, "at least one source snapshot is stale"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(statements) > 1 {
|
||||||
|
finding.State, finding.Reason = epistemics.FindingConflicted, "connector evidence contains incompatible statements"
|
||||||
|
}
|
||||||
|
for _, citation := range finding.Citations {
|
||||||
|
if citation.Conflict == epistemics.ConflictPresent {
|
||||||
|
finding.State, finding.Reason = epistemics.FindingConflicted, "connector marked evidence as conflicting"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(finding.Citations) == 0 || evaluatedAt < 1 {
|
||||||
|
return epistemics.ResearchFinding{}, ErrInvalidResearchCandidate
|
||||||
|
}
|
||||||
|
return finding, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalize(value string) string { return strings.ToLower(strings.Join(strings.Fields(value), " ")) }
|
||||||
|
|
||||||
|
// candidateConflict keeps unknown source freshness explicit at the candidate
|
||||||
|
// boundary. A connector may mark evidence as conflicting without changing the
|
||||||
|
// source; group-level incompatible statements are detected separately.
|
||||||
|
func compileResearchCandidate(candidate epistemics.ResearchCandidate) (epistemics.TruthBundle, error) {
|
||||||
|
if candidate.Coverage == "" || candidate.Conflict == "" {
|
||||||
|
return epistemics.TruthBundle{}, ErrInvalidResearchCandidate
|
||||||
|
}
|
||||||
|
citation, err := epistemics.ResolveCitation(candidate.Source, candidate.Snapshot, candidate.Excerpt, candidate.Evidence, candidate.Coverage, candidate.Conflict, candidate.Snapshot.CapturedAtUnixMillis)
|
||||||
|
if err != nil {
|
||||||
|
return epistemics.TruthBundle{}, err
|
||||||
|
}
|
||||||
|
observation := epistemics.Observation{ID: epistemics.ObservationID("observation:" + string(candidate.Excerpt.ID)), SourceSnapshot: candidate.Snapshot.ID, Excerpt: candidate.Excerpt.ID, Text: candidate.Excerpt.Text, ObservedAt: candidate.Snapshot.CapturedAtUnixMillis}
|
||||||
|
claim := epistemics.Claim{ID: epistemics.ClaimID("claim:" + string(candidate.Excerpt.ID)), Statement: candidate.Statement, ObservationID: observation.ID, CitationID: citation.ID, Verification: epistemics.VerificationUnverified}
|
||||||
|
return epistemics.TruthBundle{Observation: observation, Claim: claim, Belief: epistemics.Belief{ID: epistemics.BeliefID("belief:" + string(candidate.Excerpt.ID)), ClaimID: claim.ID, State: epistemics.BeliefProvisional}, Citation: citation}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidTruthBundle = errors.New("invalid truth bundle")
|
||||||
|
|
||||||
|
func CompileObservedClaim(source epistemics.Source, snapshot epistemics.SourceSnapshot, excerpt epistemics.SourceExcerpt, statement string, observedAt int64, conflict epistemics.ConflictState) (epistemics.TruthBundle, error) {
|
||||||
|
if strings.TrimSpace(statement) == "" || observedAt < 1 {
|
||||||
|
return epistemics.TruthBundle{}, ErrInvalidTruthBundle
|
||||||
|
}
|
||||||
|
citation, err := epistemics.ResolveCitation(source, snapshot, excerpt, epistemics.EvidenceObserved, epistemics.CoverageComplete, conflict, observedAt)
|
||||||
|
if err != nil {
|
||||||
|
return epistemics.TruthBundle{}, ErrInvalidTruthBundle
|
||||||
|
}
|
||||||
|
observation := epistemics.Observation{ID: epistemics.ObservationID("observation:" + string(excerpt.ID)), SourceSnapshot: snapshot.ID, Excerpt: excerpt.ID, Text: excerpt.Text, ObservedAt: observedAt}
|
||||||
|
claim := epistemics.Claim{ID: epistemics.ClaimID("claim:" + string(excerpt.ID)), Statement: statement, ObservationID: observation.ID, CitationID: citation.ID, Verification: epistemics.VerificationUnverified}
|
||||||
|
state := epistemics.BeliefProvisional
|
||||||
|
if conflict == epistemics.ConflictPresent || conflict == epistemics.ConflictUnknown {
|
||||||
|
state = epistemics.BeliefConflicted
|
||||||
|
}
|
||||||
|
bundle := epistemics.TruthBundle{Observation: observation, Claim: claim, Belief: epistemics.Belief{ID: epistemics.BeliefID("belief:" + string(excerpt.ID)), ClaimID: claim.ID, State: state}, Citation: citation}
|
||||||
|
if bundle.Observation.SourceSnapshot != snapshot.ID || bundle.Claim.ObservationID != bundle.Observation.ID || bundle.Claim.CitationID != bundle.Citation.ID || bundle.Belief.ClaimID != bundle.Claim.ID {
|
||||||
|
return epistemics.TruthBundle{}, ErrInvalidTruthBundle
|
||||||
|
}
|
||||||
|
return bundle, nil
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ type WorkerCycleResult struct {
|
|||||||
RecoveredClaims int64
|
RecoveredClaims int64
|
||||||
DispatchID string
|
DispatchID string
|
||||||
Completed bool
|
Completed bool
|
||||||
|
Idle bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExecuteWorkerCycle(ctx context.Context, store ports.WorkerStore, workerID string, token work.AgentFencingToken, now time.Time, staleAfter, leaseTTL time.Duration) (WorkerCycleResult, error) {
|
func ExecuteWorkerCycle(ctx context.Context, store ports.WorkerStore, workerID string, token work.AgentFencingToken, now time.Time, staleAfter, leaseTTL time.Duration) (WorkerCycleResult, error) {
|
||||||
@@ -23,6 +24,9 @@ func ExecuteWorkerCycle(ctx context.Context, store ports.WorkerStore, workerID s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return WorkerCycleResult{RecoveredClaims: recovered}, err
|
return WorkerCycleResult{RecoveredClaims: recovered}, err
|
||||||
}
|
}
|
||||||
|
if item.DispatchID == "" {
|
||||||
|
return WorkerCycleResult{RecoveredClaims: recovered, Idle: true}, nil
|
||||||
|
}
|
||||||
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())
|
_ = store.CompleteDispatch(ctx, item.DispatchID, workerID, work.AgentDispatchFailed, now.UnixMilli())
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/work"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidGraph = errors.New("invalid work graph")
|
||||||
|
ErrGraphCycle = errors.New("work graph contains a cycle")
|
||||||
|
)
|
||||||
|
|
||||||
|
func ValidateWorkGraph(graph work.WorkGraph) error {
|
||||||
|
if graph.ID == "" || graph.Revision < 1 || graph.Budget.MaxParallel < 1 || graph.Budget.MaxAttempts < 1 || len(graph.Nodes) == 0 {
|
||||||
|
return ErrInvalidGraph
|
||||||
|
}
|
||||||
|
nodes := make(map[work.GraphNodeID]struct{}, len(graph.Nodes))
|
||||||
|
keys := make(map[string]struct{}, len(graph.Nodes))
|
||||||
|
for _, node := range graph.Nodes {
|
||||||
|
if node.ID == "" || node.IdempotencyKey == "" || node.Attempts < 0 {
|
||||||
|
return ErrInvalidGraph
|
||||||
|
}
|
||||||
|
if _, exists := nodes[node.ID]; exists {
|
||||||
|
return ErrInvalidGraph
|
||||||
|
}
|
||||||
|
if _, exists := keys[node.IdempotencyKey]; exists {
|
||||||
|
return ErrInvalidGraph
|
||||||
|
}
|
||||||
|
nodes[node.ID] = struct{}{}
|
||||||
|
keys[node.IdempotencyKey] = struct{}{}
|
||||||
|
}
|
||||||
|
adjacency := make(map[work.GraphNodeID][]work.GraphNodeID, len(nodes))
|
||||||
|
for _, edge := range graph.Edges {
|
||||||
|
if edge.From == "" || edge.To == "" || edge.From == edge.To {
|
||||||
|
return ErrInvalidGraph
|
||||||
|
}
|
||||||
|
if _, ok := nodes[edge.From]; !ok {
|
||||||
|
return ErrInvalidGraph
|
||||||
|
}
|
||||||
|
if _, ok := nodes[edge.To]; !ok {
|
||||||
|
return ErrInvalidGraph
|
||||||
|
}
|
||||||
|
adjacency[edge.From] = append(adjacency[edge.From], edge.To)
|
||||||
|
}
|
||||||
|
visiting := map[work.GraphNodeID]bool{}
|
||||||
|
visited := map[work.GraphNodeID]bool{}
|
||||||
|
var visit func(work.GraphNodeID) error
|
||||||
|
visit = func(node work.GraphNodeID) error {
|
||||||
|
if visiting[node] {
|
||||||
|
return ErrGraphCycle
|
||||||
|
}
|
||||||
|
if visited[node] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
visiting[node] = true
|
||||||
|
for _, next := range adjacency[node] {
|
||||||
|
if err := visit(next); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(visiting, node)
|
||||||
|
visited[node] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for node := range nodes {
|
||||||
|
if err := visit(node); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadyGraphNodes(graph work.WorkGraph) ([]work.GraphNodeID, error) {
|
||||||
|
if err := ValidateWorkGraph(graph); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
completed := map[work.GraphNodeID]bool{}
|
||||||
|
for _, node := range graph.Nodes {
|
||||||
|
if node.State == work.GraphNodeSuccess {
|
||||||
|
completed[node.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blockedBy := map[work.GraphNodeID][]work.GraphNodeID{}
|
||||||
|
for _, edge := range graph.Edges {
|
||||||
|
blockedBy[edge.To] = append(blockedBy[edge.To], edge.From)
|
||||||
|
}
|
||||||
|
ready := make([]work.GraphNodeID, 0)
|
||||||
|
for _, node := range graph.Nodes {
|
||||||
|
if node.State != work.GraphNodeReady && node.State != work.GraphNodeBlocked {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dependenciesReady := true
|
||||||
|
for _, dependency := range blockedBy[node.ID] {
|
||||||
|
if !completed[dependency] {
|
||||||
|
dependenciesReady = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dependenciesReady {
|
||||||
|
ready = append(ready, node.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(ready, func(i, j int) bool { return ready[i] < ready[j] })
|
||||||
|
return ready, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/work"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrGraphBudgetExceeded = errors.New("work graph budget exceeded")
|
||||||
|
ErrConflictUnresolved = errors.New("conflict resolution is not explicit")
|
||||||
|
)
|
||||||
|
|
||||||
|
func ScheduleReadyNodes(graph work.WorkGraph, active int) ([]work.GraphNodeID, error) {
|
||||||
|
if err := ValidateWorkGraph(graph); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if active < 0 || active > graph.Budget.MaxParallel {
|
||||||
|
return nil, ErrGraphBudgetExceeded
|
||||||
|
}
|
||||||
|
ready, err := ReadyGraphNodes(graph)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
available := graph.Budget.MaxParallel - active
|
||||||
|
if len(ready) > available {
|
||||||
|
ready = ready[:available]
|
||||||
|
}
|
||||||
|
return ready, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdmitGraph(graph work.WorkGraph) (work.WorkGraph, error) {
|
||||||
|
if err := ValidateWorkGraph(graph); err != nil {
|
||||||
|
return work.WorkGraph{}, err
|
||||||
|
}
|
||||||
|
for _, node := range graph.Nodes {
|
||||||
|
if node.Attempts > graph.Budget.MaxAttempts {
|
||||||
|
return work.WorkGraph{}, ErrGraphBudgetExceeded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
graph.State = work.GraphAdmitted
|
||||||
|
return graph, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveGraphConflict(conflict work.ConflictSet, selectedCandidate string) (work.ConflictSet, error) {
|
||||||
|
if conflict.ID == "" || conflict.GraphID == "" || conflict.GraphRevision < 1 || conflict.NodeID == "" || conflict.Resolved || selectedCandidate == "" {
|
||||||
|
return work.ConflictSet{}, ErrConflictUnresolved
|
||||||
|
}
|
||||||
|
candidates := append([]string(nil), conflict.CandidateIDs...)
|
||||||
|
sort.Strings(candidates)
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if candidate == selectedCandidate {
|
||||||
|
conflict.CandidateIDs = candidates
|
||||||
|
conflict.Resolved = true
|
||||||
|
return conflict, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return work.ConflictSet{}, ErrConflictUnresolved
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/work"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidDevelopmentGate = errors.New("invalid governed self-development gate")
|
||||||
|
|
||||||
|
func AdvanceDevelopmentGate(current work.DevelopmentGate, next work.DevelopmentStage) (work.DevelopmentGate, error) {
|
||||||
|
if err := ValidateDevelopmentGate(current); err != nil {
|
||||||
|
return work.DevelopmentGate{}, err
|
||||||
|
}
|
||||||
|
if !validDevelopmentTransition(current.Stage, next) {
|
||||||
|
return work.DevelopmentGate{}, ErrInvalidDevelopmentGate
|
||||||
|
}
|
||||||
|
current.Stage = next
|
||||||
|
switch next {
|
||||||
|
case work.DevelopmentProposalCreated:
|
||||||
|
if current.ProposalRef == "" {
|
||||||
|
return work.DevelopmentGate{}, ErrInvalidDevelopmentGate
|
||||||
|
}
|
||||||
|
case work.DevelopmentTaskAdmitted:
|
||||||
|
if current.TaskPacketRef == "" {
|
||||||
|
return work.DevelopmentGate{}, ErrInvalidDevelopmentGate
|
||||||
|
}
|
||||||
|
case work.DevelopmentIsolated:
|
||||||
|
if current.WorktreeRef == "" {
|
||||||
|
return work.DevelopmentGate{}, ErrInvalidDevelopmentGate
|
||||||
|
}
|
||||||
|
case work.DevelopmentVerified:
|
||||||
|
if current.VerificationEvidence == "" || len(current.VerifierRunRefs) == 0 || len(current.ProducerRunRefs) == 0 || sameRuns(current.ProducerRunRefs, current.VerifierRunRefs) {
|
||||||
|
return work.DevelopmentGate{}, ErrInvalidDevelopmentGate
|
||||||
|
}
|
||||||
|
case work.DevelopmentCanary:
|
||||||
|
if current.CanaryEvidence == "" {
|
||||||
|
return work.DevelopmentGate{}, ErrInvalidDevelopmentGate
|
||||||
|
}
|
||||||
|
case work.DevelopmentPromoted:
|
||||||
|
if current.PromotionRef == "" {
|
||||||
|
return work.DevelopmentGate{}, ErrInvalidDevelopmentGate
|
||||||
|
}
|
||||||
|
case work.DevelopmentRolledBack:
|
||||||
|
if current.RollbackRef == "" {
|
||||||
|
return work.DevelopmentGate{}, ErrInvalidDevelopmentGate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateDevelopmentGate(value work.DevelopmentGate) error {
|
||||||
|
if value.ID == "" || len(value.EvidenceRefs) == 0 || !validDevelopmentStage(value.Stage) || duplicateStrings(value.EvidenceRefs) {
|
||||||
|
return ErrInvalidDevelopmentGate
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validDevelopmentTransition(current, next work.DevelopmentStage) bool {
|
||||||
|
if current == work.DevelopmentCanary && next == work.DevelopmentRolledBack {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if current == work.DevelopmentPromoted || current == work.DevelopmentRolledBack {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
order := []work.DevelopmentStage{work.DevelopmentEvidenceCollected, work.DevelopmentProposalCreated, work.DevelopmentTaskAdmitted, work.DevelopmentIsolated, work.DevelopmentVerified, work.DevelopmentCanary, work.DevelopmentPromoted}
|
||||||
|
index := func(value work.DevelopmentStage) int { return slices.Index(order, value) }
|
||||||
|
return index(next) == index(current)+1
|
||||||
|
}
|
||||||
|
|
||||||
|
func validDevelopmentStage(value work.DevelopmentStage) bool {
|
||||||
|
return slices.Contains([]work.DevelopmentStage{work.DevelopmentEvidenceCollected, work.DevelopmentProposalCreated, work.DevelopmentTaskAdmitted, work.DevelopmentIsolated, work.DevelopmentVerified, work.DevelopmentCanary, work.DevelopmentPromoted, work.DevelopmentRolledBack}, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func duplicateStrings(values []string) bool {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, value := range values {
|
||||||
|
if value == "" || seen[value] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
seen[value] = true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameRuns(left, right []work.AgentRunID) bool {
|
||||||
|
if len(left) != len(right) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, value := range left {
|
||||||
|
if !slices.Contains(right, value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/work"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGovernedSelfDevelopmentRequiresIndependentVerifierBeforeCanary(t *testing.T) {
|
||||||
|
gate := work.DevelopmentGate{ID: "dev-1", Stage: work.DevelopmentEvidenceCollected, EvidenceRefs: []string{"obs-1"}, ProposalRef: "proposal-1", TaskPacketRef: "task-1", WorktreeRef: "worktree-1", ProducerRunRefs: []work.AgentRunID{"producer"}, VerifierRunRefs: []work.AgentRunID{"verifier"}, VerificationEvidence: "verify-1", CanaryEvidence: "canary-1", PromotionRef: "promotion-1"}
|
||||||
|
for _, stage := range []work.DevelopmentStage{work.DevelopmentProposalCreated, work.DevelopmentTaskAdmitted, work.DevelopmentIsolated, work.DevelopmentVerified, work.DevelopmentCanary, work.DevelopmentPromoted} {
|
||||||
|
var err error
|
||||||
|
gate, err = AdvanceDevelopmentGate(gate, stage)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stage %s: %v", stage, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := AdvanceDevelopmentGate(gate, work.DevelopmentRolledBack); err == nil {
|
||||||
|
t.Fatal("promoted gate must be terminal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGovernedSelfDevelopmentRejectsSameProducerAndVerifier(t *testing.T) {
|
||||||
|
gate := work.DevelopmentGate{ID: "dev-1", Stage: work.DevelopmentIsolated, EvidenceRefs: []string{"obs-1"}, ProducerRunRefs: []work.AgentRunID{"same"}, VerifierRunRefs: []work.AgentRunID{"same"}, VerificationEvidence: "verify-1"}
|
||||||
|
if _, err := AdvanceDevelopmentGate(gate, work.DevelopmentVerified); err == nil {
|
||||||
|
t.Fatal("same run cannot verify its own output")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package agent_execution_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGraphPersistenceMigrationIsRevisionScoped(t *testing.T) {
|
||||||
|
data, err := os.ReadFile("../../database/migrations/031_work_graph_persistence.sql")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sql := string(data)
|
||||||
|
for _, marker := range []string{"work.graphs", "work.graph_nodes", "work.graph_edges", "PRIMARY KEY (graph_id, revision)", "FOREIGN KEY (graph_id, revision)"} {
|
||||||
|
if !strings.Contains(sql, marker) {
|
||||||
|
t.Fatalf("graph persistence marker missing: %s", marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package agent_execution_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/work"
|
||||||
|
workdomain "git.cin.su/m/Assistant/modules/work/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWorkGraphRejectsCycleAndOrdersReadyNodes(t *testing.T) {
|
||||||
|
graph := work.WorkGraph{ID: "graph-1", Revision: 1, State: work.GraphAdmitted, Budget: work.GraphBudget{MaxParallel: 2, MaxAttempts: 2}, Nodes: []work.GraphNode{
|
||||||
|
{ID: "node-b", IdempotencyKey: "key-b", State: work.GraphNodeReady},
|
||||||
|
{ID: "node-a", IdempotencyKey: "key-a", State: work.GraphNodeReady},
|
||||||
|
{ID: "node-c", IdempotencyKey: "key-c", State: work.GraphNodeBlocked},
|
||||||
|
}, Edges: []work.GraphEdge{{From: "node-a", To: "node-c"}}}
|
||||||
|
ready, err := workdomain.ReadyGraphNodes(graph)
|
||||||
|
if err != nil || len(ready) != 2 || ready[0] != "node-a" || ready[1] != "node-b" {
|
||||||
|
t.Fatalf("unexpected ready nodes: %#v %v", ready, err)
|
||||||
|
}
|
||||||
|
graph.Edges = append(graph.Edges, work.GraphEdge{From: "node-c", To: "node-a"})
|
||||||
|
if err := workdomain.ValidateWorkGraph(graph); err != workdomain.ErrGraphCycle {
|
||||||
|
t.Fatalf("expected cycle rejection, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkGraphKeepsDependentNodeBlocked(t *testing.T) {
|
||||||
|
graph := work.WorkGraph{ID: "graph-1", Revision: 1, State: work.GraphRunning, Budget: work.GraphBudget{MaxParallel: 1, MaxAttempts: 1}, Nodes: []work.GraphNode{
|
||||||
|
{ID: "source", IdempotencyKey: "source-key", State: work.GraphNodeRunning},
|
||||||
|
{ID: "dependent", IdempotencyKey: "dependent-key", State: work.GraphNodeBlocked},
|
||||||
|
}, Edges: []work.GraphEdge{{From: "source", To: "dependent"}}}
|
||||||
|
ready, err := workdomain.ReadyGraphNodes(graph)
|
||||||
|
if err != nil || len(ready) != 0 {
|
||||||
|
t.Fatalf("dependent node became ready early: %#v %v", ready, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGraphSchedulerEnforcesParallelBudgetAndExplicitConflict(t *testing.T) {
|
||||||
|
graph := work.WorkGraph{ID: "graph-1", Revision: 1, State: work.GraphDraft, Budget: work.GraphBudget{MaxParallel: 2, MaxAttempts: 2}, Nodes: []work.GraphNode{
|
||||||
|
{ID: "b", IdempotencyKey: "b-key", State: work.GraphNodeReady},
|
||||||
|
{ID: "a", IdempotencyKey: "a-key", State: work.GraphNodeReady},
|
||||||
|
}}
|
||||||
|
admitted, err := workdomain.AdmitGraph(graph)
|
||||||
|
if err != nil || admitted.State != work.GraphAdmitted {
|
||||||
|
t.Fatalf("graph was not admitted: %#v %v", admitted, err)
|
||||||
|
}
|
||||||
|
ready, err := workdomain.ScheduleReadyNodes(admitted, 1)
|
||||||
|
if err != nil || len(ready) != 1 || ready[0] != "a" {
|
||||||
|
t.Fatalf("scheduler ignored parallel budget/order: %#v %v", ready, err)
|
||||||
|
}
|
||||||
|
conflict := work.ConflictSet{ID: "conflict-1", GraphID: "graph-1", GraphRevision: 1, NodeID: "a", CandidateIDs: []string{"candidate-b", "candidate-a"}}
|
||||||
|
resolved, err := workdomain.ResolveGraphConflict(conflict, "candidate-a")
|
||||||
|
if err != nil || !resolved.Resolved || resolved.CandidateIDs[0] != "candidate-a" {
|
||||||
|
t.Fatalf("conflict was not explicitly resolved: %#v %v", resolved, err)
|
||||||
|
}
|
||||||
|
if _, err := workdomain.ResolveGraphConflict(conflict, "unknown"); err != workdomain.ErrConflictUnresolved {
|
||||||
|
t.Fatalf("unknown candidate was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package cognitive_registry_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/capability"
|
||||||
|
"git.cin.su/m/Assistant/modules/capability/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFleetPolicyRequiresAnExplicitReadyFallback(t *testing.T) {
|
||||||
|
readiness, err := domain.BuildFleetReadiness(1, []capability.FleetMember{{ProviderInstanceID: "cpu", ModelDescriptorID: "model", Tier: capability.FleetTierFast, Ready: false, Reason: "401"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := domain.SelectFleetTier(readiness, capability.FleetTierFast); err == nil {
|
||||||
|
t.Fatal("expected no invocable fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package creator_web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreatorCabinetHasAuthenticatedOperationalAndLearningSurfaces(t *testing.T) {
|
||||||
|
page, err := os.ReadFile("../../cmd/creator-web/static/index.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
markup := string(page)
|
||||||
|
for _, marker := range []string{
|
||||||
|
`<form id="login-form"`, `aria-labelledby="diagnostics-title"`, `data-page="operations"`,
|
||||||
|
`id="learning"`, `id="reflection-list"`, `id="skill-list"`, `id="settings"`,
|
||||||
|
`id="logout"`, `role="alert"`, `aria-live="polite"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(markup, marker) {
|
||||||
|
t.Fatalf("cabinet missing %q", marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatorCabinetRoutesSessionAndLearningThroughAuthenticatedAPI(t *testing.T) {
|
||||||
|
script, err := os.ReadFile("../../cmd/creator-web/static/assets/console.js")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
code := string(script)
|
||||||
|
for _, marker := range []string{
|
||||||
|
`request("/api/session/current")`, `request("/api/learning/recent?limit=8")`,
|
||||||
|
`request("/api/advisory-policy")`, `request("/api/capabilities/cognitive-proposal/health")`, `request("/api/learning/recent?limit=1")`, `sessionStorage.getItem("domovoy.creator.session")`,
|
||||||
|
`if (response.status === 401`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(code, marker) {
|
||||||
|
t.Fatalf("console missing authenticated flow %q", marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatorCabinetResponsiveAndReducedMotionRulesExist(t *testing.T) {
|
||||||
|
style, err := os.ReadFile("../../cmd/creator-web/static/assets/console.css")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
css := string(style)
|
||||||
|
for _, marker := range []string{"@media (max-width: 680px)", "@media (prefers-reduced-motion: reduce)", ".learning-grid { grid-template-columns: 1fr; }"} {
|
||||||
|
if !strings.Contains(css, marker) {
|
||||||
|
t.Fatalf("console missing responsive rule %q", marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,19 @@ function Post-CoreJson([string]$url, [object]$body) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-ContainerSecret([object[]]$environment, [string]$name) {
|
||||||
|
$fileEntry = $environment | Where-Object { $_ -like "${name}_FILE=*" } | Select-Object -First 1
|
||||||
|
if ($fileEntry) {
|
||||||
|
$path = $fileEntry.Substring("${name}_FILE=".Length)
|
||||||
|
$value = docker --context $DockerContext exec $coreContainer sh -c "cat -- '$path'"
|
||||||
|
if ($LASTEXITCODE -ne 0 -or -not $value) { throw "$name file-backed secret is not readable" }
|
||||||
|
return ($value -join "`n").TrimEnd("`r", "`n")
|
||||||
|
}
|
||||||
|
$entry = $environment | Where-Object { $_ -like "${name}=*" } | Select-Object -First 1
|
||||||
|
if (-not $entry) { throw "$name is not configured" }
|
||||||
|
return $entry.Substring("${name}=".Length)
|
||||||
|
}
|
||||||
|
|
||||||
$health = Invoke-RestMethod -Uri "$BaseURL/healthz" -Method Get
|
$health = Invoke-RestMethod -Uri "$BaseURL/healthz" -Method Get
|
||||||
if ($health.status -ne "ok" -or $health.runtime -ne "creator-web") { throw "Creator web health response is invalid" }
|
if ($health.status -ne "ok" -or $health.runtime -ne "creator-web") { throw "Creator web health response is invalid" }
|
||||||
|
|
||||||
@@ -52,19 +65,14 @@ $invalid = Invoke-WebRequest -Uri "$BaseURL/api/session/current" -Headers @{Auth
|
|||||||
if ($invalid.StatusCode -ne 401) { throw "Invalid Creator session was not denied" }
|
if ($invalid.StatusCode -ne 401) { throw "Invalid Creator session was not denied" }
|
||||||
|
|
||||||
$environment = docker --context $DockerContext inspect --format '{{range .Config.Env}}{{println .}}{{end}}' $coreContainer
|
$environment = docker --context $DockerContext inspect --format '{{range .Config.Env}}{{println .}}{{end}}' $coreContainer
|
||||||
$usernameEntry = $environment | Where-Object { $_ -like "CREATOR_USERNAME=*" } | Select-Object -First 1
|
$creatorUsername = Get-ContainerSecret $environment "CREATOR_USERNAME"
|
||||||
$passwordEntry = $environment | Where-Object { $_ -like "CREATOR_PASSWORD=*" } | Select-Object -First 1
|
$creatorPassword = Get-ContainerSecret $environment "CREATOR_PASSWORD"
|
||||||
if (-not $usernameEntry -or -not $passwordEntry) { throw "Creator username/password is not configured" }
|
|
||||||
$creatorUsername = $usernameEntry.Substring("CREATOR_USERNAME=".Length)
|
|
||||||
$creatorPassword = $passwordEntry.Substring("CREATOR_PASSWORD=".Length)
|
|
||||||
$invalidCredentials = Invoke-WebRequest -Uri "$BaseURL/api/session" -Method Post -ContentType "application/json" -Body (@{username=$creatorUsername;password="invalid"} | ConvertTo-Json -Compress) -SkipHttpErrorCheck
|
$invalidCredentials = Invoke-WebRequest -Uri "$BaseURL/api/session" -Method Post -ContentType "application/json" -Body (@{username=$creatorUsername;password="invalid"} | ConvertTo-Json -Compress) -SkipHttpErrorCheck
|
||||||
if ($invalidCredentials.StatusCode -ne 401) { throw "Invalid Creator credentials were not denied" }
|
if ($invalidCredentials.StatusCode -ne 401) { throw "Invalid Creator credentials were not denied" }
|
||||||
$issued = Invoke-RestMethod -Uri "$BaseURL/api/session" -Method Post -ContentType "application/json" -Body (@{username=$creatorUsername;password=$creatorPassword} | ConvertTo-Json -Compress)
|
$issued = Invoke-RestMethod -Uri "$BaseURL/api/session" -Method Post -ContentType "application/json" -Body (@{username=$creatorUsername;password=$creatorPassword} | ConvertTo-Json -Compress)
|
||||||
if (-not $issued.Token) { throw "Creator username/password session was not issued through the gateway" }
|
if (-not $issued.Token) { throw "Creator username/password session was not issued through the gateway" }
|
||||||
|
|
||||||
$bootstrapEntry = $environment | Where-Object { $_ -like "CREATOR_BOOTSTRAP_TOKEN=*" } | Select-Object -First 1
|
$bootstrapToken = Get-ContainerSecret $environment "CREATOR_BOOTSTRAP_TOKEN"
|
||||||
if (-not $bootstrapEntry) { throw "Creator bootstrap token is not configured" }
|
|
||||||
$bootstrapToken = $bootstrapEntry.Substring("CREATOR_BOOTSTRAP_TOKEN=".Length)
|
|
||||||
$recoveryIssued = Invoke-RestMethod -Uri "$BaseURL/api/session" -Method Post -Headers @{Authorization="Bearer $bootstrapToken"}
|
$recoveryIssued = Invoke-RestMethod -Uri "$BaseURL/api/session" -Method Post -Headers @{Authorization="Bearer $bootstrapToken"}
|
||||||
if (-not $recoveryIssued.Token) { throw "Creator recovery session was not issued through the gateway" }
|
if (-not $recoveryIssued.Token) { throw "Creator recovery session was not issued through the gateway" }
|
||||||
$current = Invoke-RestMethod -Uri "$BaseURL/api/session/current" -Method Get -Headers @{Authorization="Bearer $($issued.Token)"}
|
$current = Invoke-RestMethod -Uri "$BaseURL/api/session/current" -Method Get -Headers @{Authorization="Bearer $($issued.Token)"}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package research_contracts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReflectionCandidateLeaseSQLIsDurableAndRecoverable(t *testing.T) {
|
||||||
|
checks := map[string][]string{
|
||||||
|
"../../database/migrations/032_reflection_candidate_leases.sql": {"claim_expires_at_unix_millis", "reflection_candidates_claimable_idx"},
|
||||||
|
"../../adapters/postgres/epistemics_repository.go": {"claim_expires_at_unix_millis", "FOR UPDATE SKIP LOCKED", "state='OPEN'", "state='RESOLVED'"},
|
||||||
|
"../../cmd/reflection-worker/main.go": {"RecoverReflectionClaims", "ClaimReflectionCandidate", "ResolveReflectionCandidate"},
|
||||||
|
}
|
||||||
|
for path, needles := range checks {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", path, err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
for _, needle := range needles {
|
||||||
|
if !strings.Contains(text, needle) {
|
||||||
|
t.Fatalf("%s missing %q", path, needle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package research_contracts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||||
|
episdomain "git.cin.su/m/Assistant/modules/epistemics/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompileObservedClaimPreservesEvidenceChainAndConflict(t *testing.T) {
|
||||||
|
source := epistemics.Source{ID: "source-1", Locator: "https://example.test/source"}
|
||||||
|
snapshot := epistemics.SourceSnapshot{ID: "snapshot-1", SourceID: source.ID, ContentDigest: "sha256:test", CapturedAtUnixMillis: 100, Freshness: epistemics.FreshnessCurrent}
|
||||||
|
excerpt := epistemics.SourceExcerpt{ID: "excerpt-1", SnapshotID: snapshot.ID, Text: "exact observation", Start: 0, End: 17}
|
||||||
|
bundle, err := episdomain.CompileObservedClaim(source, snapshot, excerpt, "The observed fact is true", 200, epistemics.ConflictPresent)
|
||||||
|
if err != nil || bundle.Claim.ObservationID != bundle.Observation.ID || bundle.Belief.State != epistemics.BeliefConflicted || bundle.Citation.Evidence != epistemics.EvidenceObserved {
|
||||||
|
t.Fatalf("invalid truth bundle: %#v %v", bundle, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompileObservedClaimRejectsBrokenSourceChain(t *testing.T) {
|
||||||
|
source := epistemics.Source{ID: "source-1", Locator: "https://example.test/source"}
|
||||||
|
snapshot := epistemics.SourceSnapshot{ID: "snapshot-1", SourceID: "other-source", ContentDigest: "sha256:test", CapturedAtUnixMillis: 100, Freshness: epistemics.FreshnessCurrent}
|
||||||
|
excerpt := epistemics.SourceExcerpt{ID: "excerpt-1", SnapshotID: snapshot.ID, Text: "exact observation", Start: 0, End: 17}
|
||||||
|
if _, err := episdomain.CompileObservedClaim(source, snapshot, excerpt, "statement", 200, epistemics.ConflictNone); err != episdomain.ErrInvalidTruthBundle {
|
||||||
|
t.Fatalf("broken chain was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package research_runtime_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||||
|
app "git.cin.su/m/Assistant/modules/epistemics/application"
|
||||||
|
domain "git.cin.su/m/Assistant/modules/epistemics/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type connector struct {
|
||||||
|
candidates []epistemics.ResearchCandidate
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c connector) Collect(context.Context, epistemics.ResearchPlan) ([]epistemics.ResearchCandidate, error) {
|
||||||
|
return c.candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidate(statement string, freshness epistemics.FreshnessState, source, snapshot, excerpt string) epistemics.ResearchCandidate {
|
||||||
|
return epistemics.ResearchCandidate{PropositionKey: "domovoy", Statement: statement, Evidence: epistemics.EvidenceObserved,
|
||||||
|
Coverage: epistemics.CoverageComplete, Conflict: epistemics.ConflictNone,
|
||||||
|
Source: epistemics.Source{ID: epistemics.SourceID(source), Locator: "https://example.test/" + source},
|
||||||
|
Snapshot: epistemics.SourceSnapshot{ID: epistemics.SourceSnapshotID(snapshot), SourceID: epistemics.SourceID(source), ContentDigest: "sha256:" + snapshot, CapturedAtUnixMillis: 10, Freshness: freshness},
|
||||||
|
Excerpt: epistemics.SourceExcerpt{ID: epistemics.SourceExcerptID(excerpt), SnapshotID: epistemics.SourceSnapshotID(snapshot), Text: statement, Start: 0, End: int64(len(statement))}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResearchServiceKeepsUnknownStaleAndConflictExplicit(t *testing.T) {
|
||||||
|
plan := epistemics.ResearchPlan{ID: "plan-1", Question: "question", SourceClasses: []string{"repository"}, FreshnessPolicy: "current"}
|
||||||
|
unknown, err := (app.ResearchService{Connector: connector{}}).Evaluate(context.Background(), plan, 100)
|
||||||
|
if err != nil || len(unknown.Findings) != 1 || unknown.Findings[0].State != epistemics.FindingUnknown {
|
||||||
|
t.Fatalf("unknown evidence was not explicit: %#v %v", unknown, err)
|
||||||
|
}
|
||||||
|
result, err := (app.ResearchService{Connector: connector{candidates: []epistemics.ResearchCandidate{
|
||||||
|
candidate("one answer", epistemics.FreshnessStale, "s1", "ss1", "e1"),
|
||||||
|
candidate("another answer", epistemics.FreshnessCurrent, "s2", "ss2", "e2"),
|
||||||
|
}}}).Evaluate(context.Background(), plan, 100)
|
||||||
|
if err != nil || len(result.Findings) != 1 || result.Findings[0].State != epistemics.FindingConflicted || len(result.Findings[0].Citations) != 2 {
|
||||||
|
t.Fatalf("conflict was not retained: %#v %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateResearchRejectsFabricatedEvidenceChain(t *testing.T) {
|
||||||
|
value := candidate("answer", epistemics.FreshnessCurrent, "s1", "ss1", "e1")
|
||||||
|
value.Excerpt.SnapshotID = "other-snapshot"
|
||||||
|
plan := epistemics.ResearchPlan{ID: "plan-1", Question: "question", SourceClasses: []string{"web"}, FreshnessPolicy: "current"}
|
||||||
|
if _, err := domain.EvaluateResearch(plan, []epistemics.ResearchCandidate{value}, 100); err != domain.ErrInvalidResearchCandidate {
|
||||||
|
t.Fatalf("fabricated evidence was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResearchServiceKeepsAssumptionAndUnknownFreshnessVisible(t *testing.T) {
|
||||||
|
plan := epistemics.ResearchPlan{ID: "plan-1", Question: "question", SourceClasses: []string{"web"}, FreshnessPolicy: "current"}
|
||||||
|
value := candidate("assumed answer", epistemics.FreshnessCurrent, "s1", "ss1", "e1")
|
||||||
|
value.Evidence = epistemics.EvidenceAssumed
|
||||||
|
result, err := (app.ResearchService{Connector: connector{candidates: []epistemics.ResearchCandidate{value}}}).Evaluate(context.Background(), plan, 100)
|
||||||
|
if err != nil || result.Findings[0].State != epistemics.FindingUnknown {
|
||||||
|
t.Fatalf("assumption was promoted: %#v %v", result, err)
|
||||||
|
}
|
||||||
|
value = candidate("unknown answer", epistemics.FreshnessUnknown, "s2", "ss2", "e2")
|
||||||
|
value.Conflict = epistemics.ConflictUnknown
|
||||||
|
result, err = (app.ResearchService{Connector: connector{candidates: []epistemics.ResearchCandidate{value}}}).Evaluate(context.Background(), plan, 100)
|
||||||
|
if err != nil || result.Findings[0].State != epistemics.FindingUnknown {
|
||||||
|
t.Fatalf("unknown freshness was promoted: %#v %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package semantic_evolution_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/work"
|
||||||
|
workdomain "git.cin.su/m/Assistant/modules/work/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSelfDevelopmentRollbackIsAvailableOnlyFromCanary(t *testing.T) {
|
||||||
|
gate := work.DevelopmentGate{ID: "dev-1", Stage: work.DevelopmentCanary, EvidenceRefs: []string{"obs"}, CanaryEvidence: "canary", RollbackRef: "rollback"}
|
||||||
|
if _, err := workdomain.AdvanceDevelopmentGate(gate, work.DevelopmentRolledBack); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user