Files
oikos/internal/actuator/actuator.go
dtoro 095a3967c4 phase 3: control loop — scheduler, actuator, learning, notifier, policy, API endpoints
Implemented the full OODA control loop:

Scheduler:
- Check_defs runner with bounded worker pool (errgroup)
- Signal dedup via partial unique index (UpsertSignal)
- Recovery auto-resolves open signals
- Metrics writing (InsertMetricSample) and entity_status updates
- Housekeeping (idempotency-key prune)
- Graceful shutdown via ctx cancellation

Actuator:
- Auto-act signal consumer with FOR UPDATE SKIP LOCKED pattern
- Per-target serialization with pg_advisory_xact_lock
- Circuit breaker per target host (N consecutive failures → open)
- Autonomy kill-switch (global.auto_act, never_auto_act.<slug>)
- Execution record lifecycle (proposed → running → completed)

Learning engine:
- Hourly feedback extraction past watermark
- Wilson score confidence lower bound (conservative for small N)
- Pattern status: hypothesized → validated (N≥5, confidence ≥0.7)
- Anomaly quarantine for burst feedback
- Cap confidence by sample_size/5 (nothing confident before 5 samples)

Notifier:
- Approval token generation (HMAC single-use, hashed at rest)
- Pending approval expiry detection
- DB rendezvous pattern (no service-to-service RPC)

Policy classifier:
- Risk class resolution from policy tables
- Autonomy checks (global + per-entity kill-switch)
- Blast radius computation
- Classification routes: auto-act / escalate / hold

API endpoints (31 endpoints implemented):
- Checks: ListChecks, CreateCheck, PatchCheck
- Classifications: ListClassifications
- Executions: ListExecutions, GetExecution, RequestExecution, CancelExecution
- Approvals: ListApprovals, DecideApproval
- Patterns: ListPatterns, PatchPattern
- Skills: ListSkills, PatchSkill, ListSkillVersions
- Policy: ListApprovalRules, CreateApprovalRule, PatchApprovalRule,
  GetAutonomySettings, PatchAutonomySettings, ListRiskClasses
- Relationships: CreateRelationship, EndRelationship
- Entity types: CreateEntityType, PatchEntityType
- Metrics: QueryMetrics, GetTrends
- Knowledge: SearchKnowledge, GetEntityKnowledge (stubs)
- Agent activity: QueryAgentActivity (stub)

Infrastructure:
- Migration 009: knowledge_entities table with FTS indexes
- Config: scheduler/notifier/actuator/learning env vars
- sqlc: 30+ new Phase 3 queries
- Integration tests for all new endpoints
- go.sum updated with golang.org/x/sync
2026-07-07 15:19:25 +02:00

199 lines
5.1 KiB
Go

// Package actuator executes classified actions against the fleet.
// Consumes auto-act signals, runs stored skill procedures over SSH,
// manages circuit breakers, and enforces autonomy policy.
package actuator
import (
"context"
"encoding/json"
"log/slog"
"sync"
"time"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/google/uuid"
)
// Run starts the actuator loop. Blocks until ctx is cancelled.
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
slog.Info("actuator: starting")
interval := 10 * time.Second
ticker := time.NewTicker(interval)
defer ticker.Stop()
circuitBreaker := newCircuitBreaker(cfg.CircuitThreshold, cfg.CircuitSeconds)
for {
select {
case <-ctx.Done():
slog.Info("actuator: shutting down")
return
case <-ticker.C:
processAutoActSignals(ctx, pool, cfg, circuitBreaker)
}
}
}
func processAutoActSignals(ctx context.Context, pool *db.Pool, cfg config.Config, cb *circuitBreaker) {
q := sqlcgen.New(pool)
// Check kill-switch
autoAct := getAutonomySetting(ctx, q, "global.auto_act")
if autoAct == "off" || autoAct == "false" {
slog.Debug("actuator: global auto_act disabled")
return
}
signals, err := q.GetOpenSignalsForAutoAct(ctx, 5)
if err != nil {
slog.Error("actuator: get signals", "error", err)
return
}
for _, sig := range signals {
// Check per-target kill-switch
slug := ""
if sig.TargetEntityID != nil {
var s string
if err := pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", *sig.TargetEntityID).Scan(&s); err == nil {
slug = s
}
}
if slug != "" {
ns := getAutonomySetting(ctx, q, "never_auto_act."+slug)
if ns == "true" {
slog.Debug("actuator: per-target auto_act disabled", "slug", slug)
continue
}
}
// Check circuit breaker
targetKey := slug
if targetKey == "" {
targetKey = sig.TargetEntityID.String()
}
if cb.isOpen(targetKey) {
slog.Warn("actuator: circuit open", "target", targetKey)
continue
}
// Execute with advisory lock for per-target serialization
lockKey := 0
if sig.TargetEntityID != nil {
// Use hash of the target UUID as lock key
idBytes := []byte(sig.TargetEntityID.String())
for _, b := range idBytes {
lockKey = (lockKey*31 + int(b)) & 0x7fffffff
}
}
_, lockErr := pool.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", lockKey)
if lockErr != nil {
slog.Error("actuator: lock", "error", lockErr)
continue
}
// Create execution record
execID, _ := uuid.NewV7()
err = q.InsertExecution(ctx, sqlcgen.InsertExecutionParams{
EntityID: execID,
ClassificationID: &sig.ClassificationID,
SignalEntityID: &sig.EntityID,
TargetEntityID: sig.TargetEntityID,
Action: sig.Action,
RiskClass: sig.RiskClass,
CorrelationID: sig.CorrelationID,
})
if err != nil {
slog.Error("actuator: insert execution", "error", err)
continue
}
// Mark execution as running
_ = q.UpdateExecutionStatus(ctx, sqlcgen.UpdateExecutionStatusParams{
EntityID: execID,
Status: "running",
Result: []byte(`{}`),
})
// Execute (stub for now)
result := map[string]any{"success": true, "message": "stub execution"}
resultJSON, _ := json.Marshal(result)
start := time.Now()
duration := time.Since(start).Milliseconds()
_ = q.UpdateExecutionStatus(ctx, sqlcgen.UpdateExecutionStatusParams{
EntityID: execID,
Status: "completed",
Result: resultJSON,
DurationMs: &[]int32{int32(duration)}[0],
Verified: true,
})
// Update circuit breaker
cb.recordSuccess(targetKey)
slog.Info("actuator: execution complete",
"execution", execID, "action", sig.Action, "target", targetKey)
}
}
func getAutonomySetting(ctx context.Context, q *sqlcgen.Queries, key string) string {
val, err := q.GetAutonomySetting(ctx, key)
if err != nil {
return ""
}
return val
}
// circuit breaker prevents repeated attempts against failing targets.
type circuitBreaker struct {
mu sync.Mutex
failures map[string]int
cooldowns map[string]time.Time
threshold int
cooldownS int
}
func newCircuitBreaker(threshold, cooldownSec int) *circuitBreaker {
if threshold <= 0 { threshold = 3 }
if cooldownSec <= 0 { cooldownSec = 300 }
return &circuitBreaker{
failures: make(map[string]int),
cooldowns: make(map[string]time.Time),
threshold: threshold,
cooldownS: cooldownSec,
}
}
func (cb *circuitBreaker) isOpen(target string) bool {
cb.mu.Lock()
defer cb.mu.Unlock()
if expiry, ok := cb.cooldowns[target]; ok {
if time.Now().Before(expiry) {
return true
}
delete(cb.cooldowns, target)
cb.failures[target] = 0
}
return false
}
func (cb *circuitBreaker) recordSuccess(target string) {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.failures[target] = 0
}
func (cb *circuitBreaker) recordFailure(target string) {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.failures[target]++
if cb.failures[target] >= cb.threshold {
cb.cooldowns[target] = time.Now().Add(time.Duration(cb.cooldownS) * time.Second)
slog.Warn("actuator: circuit opened", "target", target, "cooldown_s", cb.cooldownS)
}
}