Files
AetherForge/server/internal/ai/scheduler.go
AetherForge 0002e5fd93
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Add Calibrate AI Control UI and fleet LLM backend wiring.
Operators toggle Logic gates vs AI Control on Settings, refresh local Ollama models, and save ai_endpoint settings via Calibrate PUT; server scheduler and agent snapshot/command paths support stateless 60s fleet decisions.
2026-06-07 02:14:28 -07:00

189 lines
4.1 KiB
Go

package ai
import (
"context"
"crypto/sha256"
"encoding/hex"
"log"
"sync"
"time"
)
// SnapshotProvider supplies live agent telemetry for decision cycles.
type SnapshotProvider interface {
ConnectedAgentIDs() []string
AgentSnapshot(agentID string) (AgentSnapshot, bool)
}
// CommandExecutor runs parsed fleet commands.
type CommandExecutor interface {
Execute(agentID string, cmd Command) (summary string, err error)
}
// ConfigProvider reads current Fleet AI Control settings.
type ConfigProvider interface {
AIConfig() Config
}
// DecisionStore persists decision audit rows.
type DecisionStore interface {
InsertAIDecision(agentID, promptHash, response, commandsExecuted string) error
}
// Scheduler runs periodic LLM decisions for online agents.
type Scheduler struct {
cfg ConfigProvider
snap SnapshotProvider
exec CommandExecutor
store DecisionStore
stop chan struct{}
wg sync.WaitGroup
lastRunMu sync.Mutex
lastRun map[string]time.Time
}
func NewScheduler(cfg ConfigProvider, snap SnapshotProvider, exec CommandExecutor, store DecisionStore) *Scheduler {
return &Scheduler{
cfg: cfg,
snap: snap,
exec: exec,
store: store,
stop: make(chan struct{}),
lastRun: make(map[string]time.Time),
}
}
func (s *Scheduler) Start() {
s.wg.Add(1)
go s.loop()
}
func (s *Scheduler) Stop() {
close(s.stop)
s.wg.Wait()
}
func (s *Scheduler) loop() {
defer s.wg.Done()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.stop:
return
case <-ticker.C:
s.tick()
}
}
}
// Tick runs one scheduler pass (exported for tests).
func (s *Scheduler) Tick() {
s.tick()
}
func (s *Scheduler) tick() {
if s.cfg == nil || s.snap == nil {
return
}
cfg := s.cfg.AIConfig()
if !cfg.Enabled {
return
}
interval := time.Duration(cfg.IntervalSec) * time.Second
if interval < time.Second {
interval = 60 * time.Second
}
ids := s.snap.ConnectedAgentIDs()
for i, agentID := range ids {
if !s.shouldRun(agentID, interval, i) {
continue
}
s.runAgent(context.Background(), agentID, cfg)
}
}
func (s *Scheduler) shouldRun(agentID string, interval time.Duration, staggerIndex int) bool {
s.lastRunMu.Lock()
defer s.lastRunMu.Unlock()
last, ok := s.lastRun[agentID]
if !ok {
offset := time.Duration(staggerIndex%max(1, int(interval/time.Second))) * time.Second
if offset > 0 {
s.lastRun[agentID] = time.Now().Add(-interval + offset)
}
return true
}
return time.Since(last) >= interval
}
func (s *Scheduler) markRun(agentID string) {
s.lastRunMu.Lock()
s.lastRun[agentID] = time.Now()
s.lastRunMu.Unlock()
}
func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) {
snap, ok := s.snap.AgentSnapshot(agentID)
if !ok {
return
}
userPrompt := BuildUserPrompt(snap)
systemPrompt := SystemPrompt()
promptHash := hashPrompt(userPrompt)
decide := Decide
if DecideFunc != nil {
decide = DecideFunc
}
response, err := decide(ctx, cfg.Endpoint, cfg.Model, systemPrompt, userPrompt)
if err != nil {
log.Printf("[fleet-ai] agent %s decide: %v", agentID, err)
if s.store != nil {
_ = s.store.InsertAIDecision(agentID, promptHash, "", "error:"+err.Error())
}
s.markRun(agentID)
return
}
cmds := ParseCommands(response)
results := make([]string, 0, len(cmds))
for _, cmd := range cmds {
if cmd.Type == CmdNoop {
results = append(results, "ok")
continue
}
if s.exec == nil {
results = append(results, "no executor")
continue
}
sum, execErr := s.exec.Execute(agentID, cmd)
if execErr != nil {
results = append(results, "err:"+execErr.Error())
} else {
results = append(results, sum)
}
}
executed := FormatExecuted(cmds, results)
if s.store != nil {
_ = s.store.InsertAIDecision(agentID, promptHash, response, executed)
}
s.markRun(agentID)
}
func hashPrompt(prompt string) string {
h := sha256.Sum256([]byte(prompt))
return hex.EncodeToString(h[:8])
}
// DecideFunc allows tests to override LLM calls.
var DecideFunc func(ctx context.Context, endpoint, model, systemPrompt, userPrompt string) (string, error)
func max(a, b int) int {
if a > b {
return a
}
return b
}