Files
synapbus/internal/reactor/poller.go
T
Algis DumbrisandClaude Opus 4.6 cf6066229f feat(014): add Prometheus metrics, Grafana dashboard, volume mounts, resource tuning
- Reactor Prometheus metrics: triggers_total, run_duration_seconds, agent_running, budget_used_today
- Integrated promauto metrics into hand-rolled WritePrometheus endpoint
- K8s runner: ImagePullPolicy=IfNotPresent, volume mounts, CLI args support
- Reactor: 2Gi/500m default resources (agent SDK needs it), 1h timeout
- Grafana dashboard "SynapBus Reactive Agents" with 8 panels:
  triggers by status, agent state, budget gauge, run duration,
  agent turns from Loki, reactor events log, agent container logs
- SQLite: busy_timeout=15s, synchronous=NORMAL, MaxOpenConns=4

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:15:54 +02:00

225 lines
5.9 KiB
Go

package reactor
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/dispatcher"
k8spkg "github.com/synapbus/synapbus/internal/k8s"
"github.com/synapbus/synapbus/internal/metrics"
batchv1 "k8s.io/api/batch/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)
// Poller watches active reactive runs and updates their status from K8s.
type Poller struct {
store *Store
agentStore agents.AgentStore
clientset kubernetes.Interface
runner k8spkg.JobRunner
reactor *Reactor
interval time.Duration
logger *slog.Logger
stopCh chan struct{}
}
// NewPoller creates a new job status poller.
func NewPoller(store *Store, agentStore agents.AgentStore, runner k8spkg.JobRunner, reactor *Reactor, logger *slog.Logger) *Poller {
// Extract clientset from runner if it's the real K8s runner
var clientset kubernetes.Interface
if kr, ok := runner.(*k8spkg.K8sJobRunner); ok {
clientset = kr.GetClientset()
}
return &Poller{
store: store,
agentStore: agentStore,
clientset: clientset,
runner: runner,
reactor: reactor,
interval: 15 * time.Second,
logger: logger.With("component", "reactor-poller"),
stopCh: make(chan struct{}),
}
}
// Start begins the polling loop in a background goroutine.
func (p *Poller) Start() {
if !p.runner.IsAvailable() || p.clientset == nil {
p.logger.Info("K8s not available, reactor poller disabled")
return
}
go p.pollLoop()
p.logger.Info("reactor poller started", "interval", p.interval)
}
// Stop signals the poller to stop.
func (p *Poller) Stop() {
close(p.stopCh)
}
func (p *Poller) pollLoop() {
ticker := time.NewTicker(p.interval)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
p.pollActiveRuns()
}
}
}
func (p *Poller) pollActiveRuns() {
ctx := context.Background()
runs, err := p.store.GetActiveRuns(ctx)
if err != nil {
p.logger.Error("failed to get active runs", "error", err)
return
}
for _, run := range runs {
if run.K8sJobName == "" || run.K8sNamespace == "" {
continue
}
p.checkJob(ctx, run)
}
}
func (p *Poller) checkJob(ctx context.Context, run *ReactiveRun) {
ns := run.K8sNamespace
jobName := run.K8sJobName
job, err := p.clientset.BatchV1().Jobs(ns).Get(ctx, jobName, metav1.GetOptions{})
if err != nil {
p.logger.Warn("failed to get K8s Job status", "job", jobName, "namespace", ns, "error", err)
return
}
// Check job conditions
for _, cond := range job.Status.Conditions {
switch cond.Type {
case batchv1.JobComplete:
if cond.Status == "True" {
p.handleJobComplete(ctx, run, true, "")
return
}
case batchv1.JobFailed:
if cond.Status == "True" {
reason := cond.Reason
if cond.Message != "" {
reason = reason + ": " + cond.Message
}
p.handleJobComplete(ctx, run, false, reason)
return
}
}
}
// Check if active deadline exceeded
if job.Status.Failed > 0 {
p.handleJobComplete(ctx, run, false, "job failed (pod failure)")
return
}
}
func (p *Poller) handleJobComplete(ctx context.Context, run *ReactiveRun, success bool, failureReason string) {
now := time.Now().UTC()
// Update metrics
metrics.ReactiveAgentState.WithLabelValues(run.AgentName).Set(0)
if run.StartedAt != nil {
duration := now.Sub(*run.StartedAt).Seconds()
metrics.ReactiveRunDuration.WithLabelValues(run.AgentName).Observe(duration)
}
todayCount, _ := p.store.CountTodayRuns(ctx, run.AgentName)
metrics.ReactiveBudgetUsed.WithLabelValues(run.AgentName).Set(float64(todayCount))
if success {
metrics.ReactiveTriggersTotal.WithLabelValues(run.AgentName, StatusSucceeded).Inc()
_ = p.store.CompleteRun(ctx, run.ID, StatusSucceeded, "", now)
p.logger.Info("reactive run succeeded",
"agent", run.AgentName,
"job", run.K8sJobName,
"run_id", run.ID,
)
} else {
// Retrieve logs
errorLog := failureReason
logs, err := p.runner.GetJobLogs(ctx, run.K8sNamespace, run.K8sJobName)
if err == nil && logs != "" {
// Keep last 100 lines
lines := strings.Split(logs, "\n")
if len(lines) > 100 {
lines = lines[len(lines)-100:]
}
errorLog = strings.Join(lines, "\n")
}
metrics.ReactiveTriggersTotal.WithLabelValues(run.AgentName, StatusFailed).Inc()
_ = p.store.CompleteRun(ctx, run.ID, StatusFailed, errorLog, now)
p.logger.Warn("reactive run failed",
"agent", run.AgentName,
"job", run.K8sJobName,
"run_id", run.ID,
"reason", failureReason,
)
// Send failure notification
var durationMs int64
if run.StartedAt != nil {
durationMs = now.Sub(*run.StartedAt).Milliseconds()
}
agent, err := p.agentStore.GetAgentByName(ctx, run.AgentName)
if err == nil && agent != nil {
event := dispatcher.MessageEvent{
EventType: run.TriggerEvent,
FromAgent: run.TriggerFrom,
}
p.reactor.notifyFailure(ctx, agent, event, durationMs, fmt.Sprintf("Job %s failed: %s", run.K8sJobName, failureReason))
}
}
// Check for pending_work — launch coalesced run if needed
p.checkPendingWork(ctx, run.AgentName)
}
func (p *Poller) checkPendingWork(ctx context.Context, agentName string) {
agent, err := p.agentStore.GetAgentByName(ctx, agentName)
if err != nil {
return
}
if !agent.PendingWork {
return
}
// Clear pending_work first
_ = p.agentStore.SetPendingWork(ctx, agentName, false)
p.logger.Info("pending_work found, launching coalesced run", "agent", agentName)
// Create a synthetic event (coalesced — agent will pick up all pending messages via claim_messages)
event := dispatcher.MessageEvent{
EventType: "message.received",
FromAgent: "system",
ToAgent: agentName,
Body: "Coalesced trigger: process all pending messages.",
MentionedAgents: nil,
Depth: 0,
}
// Evaluate the trigger (it will check cooldown/budget again)
_ = p.reactor.evaluateTrigger(ctx, agentName, event)
}