package api import ( "context" "log/slog" "sync" "sync/atomic" "gitea.stevedudenhoeffer.com/steve/pansy/internal/agent" "gitea.stevedudenhoeffer.com/steve/pansy/internal/service" ) // agentHolder owns the live assistant Runner and lets an admin swap it at // runtime when the model settings change (#79). // // The Runner used to be a plain handlers field set once at boot, with the chat // routes registered only when it existed. That made the assistant permanently // whatever the environment said at startup. Now the routes are always // registered and the Runner lives behind an atomic pointer, so a settings change // can turn the assistant on, off, or onto a different model without a restart — // and without a data race against in-flight requests reading the pointer. // // A nil pointer means "no assistant right now"; handlers nil-check get() rather // than assuming a Runner is present. type agentHolder struct { svc *service.Service ptr atomic.Pointer[agent.Runner] // rebuildMu serializes rebuilds so two concurrent settings saves can't // interleave into a torn "resolve A, resolve B, store A, store B" swap. The // read path (get) stays lock-free on the atomic pointer. rebuildMu sync.Mutex } // newAgentHolder builds the holder and resolves the initial Runner from whatever // the settings + environment currently say. A resolution failure is logged and // left as "no assistant", never fatal: a garden planner must still boot. func newAgentHolder(ctx context.Context, svc *service.Service) *agentHolder { h := &agentHolder{svc: svc} h.rebuild(ctx) return h } // get returns the current Runner, or nil if the assistant is off. func (h *agentHolder) get() *agent.Runner { return h.ptr.Load() } // rebuild resolves the effective agent configuration and swaps the Runner to // match: a new one when the assistant should be on, nil when it shouldn't. It is // safe to call at boot and from a settings save; concurrent calls serialize. // // It logs what it did rather than returning an error, because every caller wants // the same thing — best-effort apply, keep serving either way — and a settings // save must not fail just because the new model won't resolve. The save already // validated the spec; a rebuild failure here means the environment changed under // it, and "assistant off, with a reason in the log" is the right outcome. func (h *agentHolder) rebuild(ctx context.Context) { h.rebuildMu.Lock() defer h.rebuildMu.Unlock() eff, err := h.svc.EffectiveAgent(ctx) if err != nil { // EffectiveAgent errors only if the settings row can't be read — a DB fault, // and a very transient one when it happens right after a settings write. We // keep the current Runner rather than tear down a working assistant on a // blip: the state is already persisted, so the next rebuild (any later save, // or a restart) reconciles it. Loud, because a persistent failure here means // the live assistant no longer matches stored settings. slog.Error("api: could not resolve agent settings; leaving the assistant as-is", "error", err) return } if !eff.Ready() { if h.ptr.Swap(nil) != nil { slog.Info("api: garden assistant turned off", "enabled", eff.Enabled, "hasKey", eff.APIKey != "", "hasModel", eff.Model != "") } return } runner, err := agent.NewRunner(h.svc, eff.APIKey, eff.Model) if err != nil { // Configured but unusable. Turn the assistant off rather than leaving a // stale Runner on the old model — an admin who just pointed it at a broken // spec should see it stop, not silently keep answering on the previous one. slog.Error("api: garden assistant disabled (model won't resolve)", "error", err, "model", eff.Model) h.ptr.Store(nil) return } h.ptr.Store(runner) slog.Info("api: garden assistant ready", "model", eff.Model) }