Files
goclaw/cmd/gateway_evolution_cron.go
T
viettranx 6d43de4169 fix(evolution): fix 7 bugs in evolution flow — guardrails, cron, dedup, dead types
- Remove broken delta comparison in CheckGuardrails (compared usage rate vs max delta)
- Replace hardcoded +0.05 threshold bump with configurable MaxDeltaPerCycle, cap at 0.95
- Remove dead MetricFeedback and SuggestMemoryPrune constants + UI references
- Make SuggestToolOrder approval actionable — disables tool via BuiltinToolTenantConfigStore
- Replace time.Ticker(24h) with wall-clock scheduling (3 AM daily, Sundays weekly eval)
- Add PG advisory lock on pinned connection for multi-instance safety
- Add EvolutionSuggest flag check in cron (metrics-only agents skip suggestion analysis)
- Change suggestion dedup from type-only to (type, metric_key) composite key
2026-04-10 10:13:58 +07:00

160 lines
4.9 KiB
Go

package cmd
import (
"context"
"database/sql"
"log/slog"
"time"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// evolutionCronLockID is a PG advisory lock ID for preventing duplicate cron runs.
// Only one gateway instance should run evolution analysis at a time.
const evolutionCronLockID int64 = 0x65766F6C // "evol"
// runEvolutionCron runs the v3 evolution suggestion engine (daily at 3:00 AM)
// and evaluation/rollback check (weekly on Sundays at 3:00 AM) as a background goroutine.
// Designed to be called with `go runEvolutionCron(...)`.
func runEvolutionCron(stores *store.Stores, engine *agent.SuggestionEngine) {
// Wait 1 minute after startup for warm-up, then run first analysis.
time.Sleep(1 * time.Minute)
runSuggestionAnalysis(stores, engine)
for {
nextDaily := nextDailyRun(3) // 3:00 AM server time
timer := time.NewTimer(time.Until(nextDaily))
<-timer.C
timer.Stop()
runSuggestionAnalysis(stores, engine)
// Weekly evaluation on Sundays.
if time.Now().Weekday() == time.Sunday {
runEvolutionEvaluation(stores)
}
}
}
// nextDailyRun returns the next occurrence of the given hour (0-23) today or tomorrow.
// Uses AddDate to handle DST transitions correctly (calendar day, not 24h).
func nextDailyRun(hour int) time.Time {
now := time.Now()
next := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, now.Location())
if !now.Before(next) {
next = next.AddDate(0, 0, 1) // calendar day, not 24h — DST-safe
}
return next
}
// tryAdvisoryLock acquires a PG session-level advisory lock on a pinned connection.
// Returns the pinned connection (caller must close) and whether the lock was acquired.
// No-op (returns nil, true) when db is nil (SQLite/desktop edition runs single-instance).
func tryAdvisoryLock(ctx context.Context, db *sql.DB) (*sql.Conn, bool) {
if db == nil {
return nil, true
}
conn, err := db.Conn(ctx)
if err != nil {
slog.Debug("evolution.cron.conn_failed", "error", err)
return nil, false
}
var acquired bool
if err := conn.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1)", evolutionCronLockID).Scan(&acquired); err != nil {
conn.Close()
slog.Debug("evolution.cron.advisory_lock_failed", "error", err)
return nil, false
}
if !acquired {
conn.Close()
return nil, false
}
return conn, true
}
// releaseAdvisoryLock releases a PG advisory lock on the pinned connection and closes it.
func releaseAdvisoryLock(ctx context.Context, conn *sql.Conn) {
if conn == nil {
return
}
_, _ = conn.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", evolutionCronLockID)
conn.Close()
}
// runSuggestionAnalysis lists agents with evolution enabled and runs analysis.
// Note: List(ctx, "") uses bare context (no tenant) to list ALL agents cross-tenant.
// Per-agent calls are scoped via WithTenantID.
func runSuggestionAnalysis(stores *store.Stores, engine *agent.SuggestionEngine) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// Acquire advisory lock on a pinned connection to prevent duplicate runs across instances.
conn, acquired := tryAdvisoryLock(ctx, stores.DB)
if !acquired {
slog.Debug("evolution.cron.skipped_lock_held")
return
}
defer releaseAdvisoryLock(ctx, conn)
agents, err := stores.Agents.List(ctx, "")
if err != nil {
slog.Warn("evolution.cron.list_agents_failed", "error", err)
return
}
var count int
for _, ag := range agents {
if ag.Status != store.AgentStatusActive {
continue
}
flags := ag.ParseV3Flags()
if !flags.EvolutionMetrics {
continue
}
if !flags.EvolutionSuggest {
continue // metrics enabled but suggestions disabled — skip analysis
}
agentCtx := store.WithTenantID(ctx, ag.TenantID)
if _, err := engine.Analyze(agentCtx, ag.ID); err != nil {
slog.Debug("evolution.cron.analyze_failed", "agent", ag.ID, "error", err)
}
count++
}
if count > 0 {
slog.Info("evolution.cron.analysis_complete", "agents", count)
}
}
// runEvolutionEvaluation checks applied suggestions and rolls back quality drops.
func runEvolutionEvaluation(stores *store.Stores) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
agents, err := stores.Agents.List(ctx, "")
if err != nil {
slog.Warn("evolution.cron.eval_list_failed", "error", err)
return
}
guardrails := agent.DefaultGuardrails()
for _, ag := range agents {
if ag.Status != store.AgentStatusActive {
continue
}
flags := ag.ParseV3Flags()
if !flags.EvolutionMetrics {
continue
}
if !flags.EvolutionSuggest {
continue // skip evaluation for agents with suggestions disabled
}
agentCtx := store.WithTenantID(ctx, ag.TenantID)
if err := agent.EvaluateApplied(agentCtx, ag.ID, guardrails, stores.EvolutionMetrics, stores.EvolutionSuggestions, stores.Agents); err != nil {
slog.Debug("evolution.cron.eval_failed", "agent", ag.ID, "error", err)
}
}
}