mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 02:15:57 +00:00
- 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
160 lines
4.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|