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
This commit is contained in:
2026-07-07 15:19:25 +02:00
parent 7e802bbb14
commit 095a3967c4
17 changed files with 4692 additions and 150 deletions

View File

@@ -0,0 +1,169 @@
// Package learning implements the Oikos learning engine (Phase 3).
// Hourly pattern extraction: reads feedback past the watermark, groups by
// (applies_type, action), updates pattern counters with Wilson confidence,
// detects anomalies, and refines skills.
package learning
import (
"context"
"log/slog"
"math"
"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 learning loop. Blocks until ctx is cancelled.
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
slog.Info("learning: starting", "interval", cfg.LearningInterval)
interval := cfg.LearningInterval
if interval <= 0 {
interval = 1 * time.Hour
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
watermark := time.Now().Add(-24 * time.Hour) // start from 24h ago
for {
select {
case <-ctx.Done():
slog.Info("learning: shutting down")
return
case <-ticker.C:
watermark = extractPatterns(ctx, pool, watermark)
}
}
}
// extractPatterns reads feedback past the watermark, groups by (type, action),
// updates pattern counters, and returns the new watermark.
func extractPatterns(ctx context.Context, pool *db.Pool, watermark time.Time) time.Time {
q := sqlcgen.New(pool)
feedback, err := q.GetFeedbackAfterWatermark(ctx, watermark)
if err != nil {
slog.Error("learning: get feedback", "error", err)
return watermark
}
if len(feedback) == 0 {
// Advance watermark to now so we don't re-scan
return time.Now()
}
// Group by (applies_type, action)
type groupKey struct {
Type string
Action string
}
groups := make(map[groupKey][]sqlcgen.GetFeedbackAfterWatermarkRow)
for _, f := range feedback {
key := groupKey{Type: f.AppliesType, Action: f.Action}
groups[key] = append(groups[key], f)
}
for key, items := range groups {
processGroup(ctx, pool, q, key.Type, key.Action, items)
}
// Update watermark to the latest feedback timestamp
newWatermark := watermark
for _, f := range feedback {
if f.CreatedAt.After(newWatermark) {
newWatermark = f.CreatedAt
}
}
return newWatermark
}
func processGroup(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
appliesType, action string, items []sqlcgen.GetFeedbackAfterWatermarkRow) {
successCount := 0
failureCount := 0
for _, f := range items {
switch f.Outcome {
case "success":
successCount++
case "failure", "unexpected":
failureCount++
case "partial":
successCount++ // partial counts as half-success
}
}
total := successCount + failureCount
if total == 0 {
return
}
// Compute Wilson score lower bound
confidence := wilsonLowerBound(float64(successCount), float64(total), 0.95)
// Cap by sample size: nothing looks confident before 5 samples
confidence = math.Min(confidence, float64(total)/5.0)
// Get or create pattern
patternID, _ := uuid.NewV7()
patternSummary := action + " on " + appliesType
err := q.UpsertPattern(ctx, sqlcgen.UpsertPatternParams{
EntityID: patternID,
AppliesType: appliesType,
Action: action,
Pattern: patternSummary,
Confidence: float32(confidence),
EvidenceCount: int32(total),
SuccessCount: int32(successCount),
FailureCount: int32(failureCount),
})
if err != nil {
slog.Error("learning: upsert pattern", "error", err)
return
}
// Update pattern status based on confidence
pat, err := q.GetPattern(ctx, sqlcgen.GetPatternParams{
AppliesType: appliesType,
Action: action,
})
if err != nil {
return
}
if pat.EvidenceCount >= 5 && pat.Confidence >= 0.7 && !pat.Quarantined {
_ = q.UpdatePatternStatus(ctx, sqlcgen.UpdatePatternStatusParams{
EntityID: pat.EntityID,
Status: "validated",
})
slog.Info("learning: pattern validated",
"type", appliesType, "action", action,
"confidence", confidence, "samples", total)
}
// Anomaly check: >10 identical outcomes within 1h
if total > 10 {
_ = q.UpdatePatternQuarantine(ctx, sqlcgen.UpdatePatternQuarantineParams{
EntityID: pat.EntityID,
Quarantined: true,
})
slog.Warn("learning: pattern quarantined (anomaly burst)",
"type", appliesType, "action", action)
}
}
// wilsonLowerBound computes the Wilson score interval lower bound.
// Conservative estimate of success rate for small sample sizes.
func wilsonLowerBound(success, total, z float64) float64 {
if total == 0 {
return 0
}
p := success / total
z2 := z * z
denom := 1 + z2/total
center := (p + z2/(2*total)) / denom
sp := math.Sqrt((p*(1-p) + z2/(4*total)) / total) / denom
return math.Max(0, center-z*sp)
}