Files
kandev/apps/backend/internal/agent/settings/controller/reconciler.go
T
2026-04-15 13:22:32 +01:00

308 lines
9.7 KiB
Go

package controller
import (
"context"
"database/sql"
"errors"
"fmt"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/hostutility"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/agent/settings/store"
"github.com/kandev/kandev/internal/common/logger"
)
// CapabilityReader is the minimum surface of the host utility manager that
// the reconciler needs. Declared as an interface so tests can inject a fake.
type CapabilityReader interface {
Get(agentType string) (hostutility.AgentCapabilities, bool)
}
// ProfileReconciler reconciles persisted agent profiles against the host
// utility capability cache. On boot it seeds default profiles for newly
// probed agents, validates existing profile models/modes against the cache
// (auto-healing stale values), and soft-deletes profiles whose agent_id is
// no longer registered (e.g. after removing the non-ACP agent variants).
//
// The reconciler is idempotent and safe to run on every boot. It runs in a
// goroutine after hostUtility.Start so probe results are available, and
// never blocks task startup — profiles used before reconciliation simply get
// validated on the next boot.
type ProfileReconciler struct {
hostUtility CapabilityReader
registry *registry.Registry
store store.Repository
log *logger.Logger
}
// NewProfileReconciler constructs a reconciler.
func NewProfileReconciler(
h CapabilityReader,
reg *registry.Registry,
st store.Repository,
log *logger.Logger,
) *ProfileReconciler {
return &ProfileReconciler{
hostUtility: h,
registry: reg,
store: st,
log: log.WithFields(zap.String("component", "profile-reconciler")),
}
}
// Run executes one reconciliation pass: orphan cleanup, default seeding, and
// stale model/mode healing. Errors are logged and the pass continues — the
// goal is best-effort convergence, not atomicity.
func (r *ProfileReconciler) Run(ctx context.Context) error {
if r == nil || r.hostUtility == nil || r.registry == nil || r.store == nil {
return fmt.Errorf("reconciler not fully configured")
}
// Orphan cleanup first: removed agents can't come back, regardless of
// probe state, so we can clean these unconditionally.
r.cleanupOrphans(ctx)
// Walk enabled inference agents and reconcile each one.
for _, ia := range r.registry.ListInferenceAgents() {
ag, ok := ia.(agents.Agent)
if !ok {
continue
}
r.reconcileAgent(ctx, ag)
}
return nil
}
// cleanupOrphans soft-deletes profiles whose DB agent row references an
// agent type that is no longer registered or enabled (e.g. profiles left
// behind by the removed streamjson-based variants). The settings store keys
// each DB agent by a UUID in `id`, with the registry-facing identifier stored
// in `name` — we match against the registry on `name`.
func (r *ProfileReconciler) cleanupOrphans(ctx context.Context) {
enabled := make(map[string]struct{})
for _, ag := range r.registry.ListEnabled() {
enabled[ag.ID()] = struct{}{}
}
dbAgents, err := r.store.ListAgents(ctx)
if err != nil {
r.log.Warn("orphan cleanup: list agents failed", zap.Error(err))
return
}
for _, dbAgent := range dbAgents {
if _, ok := enabled[dbAgent.Name]; ok {
continue
}
profiles, err := r.store.ListAgentProfiles(ctx, dbAgent.ID)
if err != nil {
r.log.Warn("orphan cleanup: list profiles failed",
zap.String("agent_id", dbAgent.ID),
zap.String("agent_name", dbAgent.Name),
zap.Error(err))
continue
}
for _, p := range profiles {
r.log.Info("soft-deleting orphan profile",
zap.String("profile_id", p.ID),
zap.String("agent_id", p.AgentID),
zap.String("agent_name", dbAgent.Name))
if err := r.store.DeleteAgentProfile(ctx, p.ID); err != nil {
r.log.Warn("orphan cleanup: delete failed",
zap.String("profile_id", p.ID), zap.Error(err))
}
}
}
}
// reconcileAgent validates or seeds profiles for a single inference agent.
// When the agent's probe is not "ok", existing profiles are left untouched —
// the UI surfaces the probe error and the user fixes it before we retry.
func (r *ProfileReconciler) reconcileAgent(ctx context.Context, ag agents.Agent) {
agentType := ag.ID()
caps, ok := r.hostUtility.Get(agentType)
if !ok || caps.Status != hostutility.StatusOK {
r.log.Debug("skipping reconciliation: probe not ok",
zap.String("agent_id", agentType),
zap.String("status", string(caps.Status)))
return
}
dbAgent, err := r.ensureDBAgent(ctx, ag)
if err != nil {
r.log.Warn("reconcile: ensure db agent failed",
zap.String("agent_id", agentType), zap.Error(err))
return
}
profiles, err := r.store.ListAgentProfiles(ctx, dbAgent.ID)
if err != nil {
r.log.Warn("reconcile: list profiles failed",
zap.String("agent_id", agentType), zap.Error(err))
return
}
if len(profiles) == 0 {
r.seedDefaultProfile(ctx, ag, dbAgent, caps)
return
}
for _, p := range profiles {
r.healProfile(ctx, p, caps)
}
}
// ensureDBAgent looks up the agent row in the store or creates one if missing.
// The store's agent row uses an auto-generated UUID for `ID`; `Name` holds
// the registry-facing identifier like "claude-acp".
//
// Callers must distinguish "not found" from transient DB errors to avoid
// duplicate rows on lock/timeout.
func (r *ProfileReconciler) ensureDBAgent(ctx context.Context, ag agents.Agent) (*models.Agent, error) {
dbAgent, err := r.store.GetAgentByName(ctx, ag.ID())
if err == nil && dbAgent != nil {
return dbAgent, nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
// Transient DB error — don't attempt to create, caller will retry.
return nil, fmt.Errorf("get agent %q: %w", ag.ID(), err)
}
// Not found: create a new row. The store assigns ID on CreateAgent.
newAgent := &models.Agent{
Name: ag.ID(),
SupportsMCP: true,
}
if createErr := r.store.CreateAgent(ctx, newAgent); createErr != nil {
return nil, createErr
}
return newAgent, nil
}
// seedDefaultProfile creates a single default profile for an agent that has no
// existing profiles, using the probed CurrentModelID and CurrentModeID.
func (r *ProfileReconciler) seedDefaultProfile(
ctx context.Context,
ag agents.Agent,
dbAgent *models.Agent,
caps hostutility.AgentCapabilities,
) {
profile := &models.AgentProfile{
AgentID: dbAgent.ID,
Name: profileNameFromCaps(ag, caps),
AgentDisplayName: ag.DisplayName(),
Model: caps.CurrentModelID,
Mode: caps.CurrentModeID,
AllowIndexing: ag.ID() == "auggie",
CLIPassthrough: false,
UserModified: false,
}
if err := r.store.CreateAgentProfile(ctx, profile); err != nil {
r.log.Warn("seed default profile failed",
zap.String("agent_id", dbAgent.ID), zap.Error(err))
return
}
r.log.Info("seeded default profile from probe",
zap.String("profile_id", profile.ID),
zap.String("agent_id", dbAgent.ID),
zap.String("model", profile.Model),
zap.String("mode", profile.Mode))
}
// healProfile validates the profile's model and mode against the cache and
// auto-heals values that no longer exist. User-modified profiles are still
// healed — we always keep profiles in a usable state; the "user_modified"
// flag survives the write to retain user intent for other fields.
func (r *ProfileReconciler) healProfile(
ctx context.Context,
p *models.AgentProfile,
caps hostutility.AgentCapabilities,
) {
changed := healProfileName(p, caps)
if p.Model != "" && !modelExists(p.Model, caps.Models) {
r.log.Info("profile model no longer available, auto-healing",
zap.String("profile_id", p.ID),
zap.String("old_model", p.Model),
zap.String("new_model", caps.CurrentModelID))
p.Model = caps.CurrentModelID
changed = true
}
if p.Model == "" && caps.CurrentModelID != "" {
p.Model = caps.CurrentModelID
changed = true
}
if p.Mode != "" && !modeExists(p.Mode, caps.Modes) {
r.log.Info("profile mode no longer available, clearing",
zap.String("profile_id", p.ID),
zap.String("old_mode", p.Mode))
p.Mode = ""
changed = true
}
if p.Mode == "" && caps.CurrentModeID != "" {
p.Mode = caps.CurrentModeID
changed = true
}
if !changed {
return
}
if err := r.store.UpdateAgentProfile(ctx, p); err != nil {
r.log.Warn("profile heal update failed",
zap.String("profile_id", p.ID), zap.Error(err))
}
}
// healProfileName updates the profile name when it still matches the agent
// display name (stale seed from before we started naming profiles after the
// current model). User-modified profiles are skipped.
func healProfileName(p *models.AgentProfile, caps hostutility.AgentCapabilities) bool {
if p.UserModified || p.Name != p.AgentDisplayName || caps.CurrentModelID == "" {
return false
}
for _, m := range caps.Models {
if m.ID == caps.CurrentModelID && m.Name != "" && m.Name != p.Name {
p.Name = m.Name
return true
}
}
return false
}
// profileNameFromCaps picks a user-facing default profile name from the
// probed model list: prefer the Name of the agent's currentModelId, fall
// back to the model id, then the agent display name. Keeps the profile
// label meaningful ("Claude Sonnet 4.6") instead of the agent name echoed.
func profileNameFromCaps(ag agents.Agent, caps hostutility.AgentCapabilities) string {
if caps.CurrentModelID != "" {
for _, m := range caps.Models {
if m.ID == caps.CurrentModelID && m.Name != "" {
return m.Name
}
}
return caps.CurrentModelID
}
return ag.DisplayName()
}
func modelExists(id string, models []hostutility.Model) bool {
for _, m := range models {
if m.ID == id {
return true
}
}
return false
}
func modeExists(id string, modes []hostutility.Mode) bool {
for _, m := range modes {
if m.ID == id {
return true
}
}
return false
}