Skip to content

Commit 32aa9d8

Browse files
authored
Fix bot config changes not taking effect until plugin restart (#508)
1 parent c48e19d commit 32aa9d8

2 files changed

Lines changed: 415 additions & 20 deletions

File tree

bots/bots.go

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"io"
1010
"net/http"
11+
"reflect"
1112
"sync"
1213

1314
"github.com/mattermost/mattermost-plugin-ai/anthropic"
@@ -52,9 +53,12 @@ type MMBots struct {
5253
botsLock sync.RWMutex
5354
bots []*Bot
5455

55-
// lastEnsuredBotCfgs stores the bot configs that were last successfully ensured
56-
// This is used for optimistic checking to avoid unnecessary cluster mutex acquisition
56+
// lastEnsuredBotCfgs stores the bot configs that were last successfully ensured.
57+
// This is used for optimistic checking to avoid unnecessary cluster mutex acquisition.
5758
lastEnsuredBotCfgs []llm.BotConfig
59+
// lastEnsuredServiceCfgs stores the resolved service configs keyed by service ID
60+
// that were last successfully ensured, for optimistic change detection.
61+
lastEnsuredServiceCfgs map[string]llm.ServiceConfig
5862
}
5963

6064
func New(mutexPluginAPI cluster.MutexPluginAPI, pluginAPI *pluginapi.Client, licenseChecker *enterprise.LicenseChecker, config Config, llmUpstreamHTTPClient *http.Client, tokenLogger *mlog.Logger, metrics llm.MetricsObserver) *MMBots {
@@ -69,14 +73,29 @@ func New(mutexPluginAPI cluster.MutexPluginAPI, pluginAPI *pluginapi.Client, lic
6973
}
7074
}
7175

72-
// botConfigsEqual compares two bot config slices for equality
73-
// This is used for optimistic checking to avoid unnecessary cluster mutex acquisition
76+
// resolveServiceCfgs builds a map of service configs referenced by the given bot configs.
77+
func (b *MMBots) resolveServiceCfgs(botCfgs []llm.BotConfig) map[string]llm.ServiceConfig {
78+
result := make(map[string]llm.ServiceConfig, len(botCfgs))
79+
for _, botCfg := range botCfgs {
80+
if _, exists := result[botCfg.ServiceID]; exists {
81+
continue
82+
}
83+
if svc, ok := b.config.GetServiceByID(botCfg.ServiceID); ok {
84+
result[botCfg.ServiceID] = svc
85+
}
86+
}
87+
return result
88+
}
89+
90+
// botConfigsEqual compares two bot config slices for equality.
91+
// Uses reflect.DeepEqual to compare all fields, ensuring changes to any field
92+
// (e.g., EnabledNativeTools, CustomInstructions, access levels) are detected.
93+
// Comparison is order-independent, matching configs by ID.
7494
func botConfigsEqual(a, b []llm.BotConfig) bool {
7595
if len(a) != len(b) {
7696
return false
7797
}
7898

79-
// Build a map of bot configs by ID for efficient comparison
8099
aMap := make(map[string]llm.BotConfig, len(a))
81100
for _, cfg := range a {
82101
aMap[cfg.ID] = cfg
@@ -87,11 +106,26 @@ func botConfigsEqual(a, b []llm.BotConfig) bool {
87106
if !ok {
88107
return false
89108
}
90-
// Compare all fields that affect bot setup
91-
if aCfg.Name != cfg.Name ||
92-
aCfg.DisplayName != cfg.DisplayName ||
93-
aCfg.ServiceID != cfg.ServiceID ||
94-
aCfg.Model != cfg.Model {
109+
if !reflect.DeepEqual(aCfg, cfg) {
110+
return false
111+
}
112+
}
113+
114+
return true
115+
}
116+
117+
// serviceConfigsEqual compares two service config maps for equality.
118+
func serviceConfigsEqual(a, b map[string]llm.ServiceConfig) bool {
119+
if len(a) != len(b) {
120+
return false
121+
}
122+
123+
for id, aCfg := range a {
124+
bCfg, ok := b[id]
125+
if !ok {
126+
return false
127+
}
128+
if !reflect.DeepEqual(aCfg, bCfg) {
95129
return false
96130
}
97131
}
@@ -100,17 +134,19 @@ func botConfigsEqual(a, b []llm.BotConfig) bool {
100134
}
101135

102136
func (b *MMBots) EnsureBots() error {
103-
// Optimistic check: if bot configuration hasn't changed since last ensure,
137+
// Optimistic check: if bot and service configuration hasn't changed since last ensure,
104138
// skip the expensive cluster mutex acquisition. This prevents HA timeout issues
105139
// when multiple nodes all try to acquire the mutex simultaneously on config changes.
106140
currentBotCfgs := b.config.GetBots()
141+
currentServiceCfgs := b.resolveServiceCfgs(currentBotCfgs)
107142
b.botsLock.RLock()
108143
botsAlreadyInitialized := len(b.bots) > 0
109-
lastCfgs := b.lastEnsuredBotCfgs
144+
lastBotCfgs := b.lastEnsuredBotCfgs
145+
lastServiceCfgs := b.lastEnsuredServiceCfgs
110146
b.botsLock.RUnlock()
111147

112-
if botsAlreadyInitialized && botConfigsEqual(lastCfgs, currentBotCfgs) {
113-
b.pluginAPI.Log.Debug("EnsureBots: skipping - bot configuration unchanged")
148+
if botsAlreadyInitialized && botConfigsEqual(lastBotCfgs, currentBotCfgs) && serviceConfigsEqual(lastServiceCfgs, currentServiceCfgs) {
149+
b.pluginAPI.Log.Debug("EnsureBots: skipping - bot and service configuration unchanged")
114150
return nil
115151
}
116152

@@ -123,13 +159,15 @@ func (b *MMBots) EnsureBots() error {
123159

124160
// Re-check after acquiring lock - another node may have already handled this
125161
currentBotCfgs = b.config.GetBots()
162+
currentServiceCfgs = b.resolveServiceCfgs(currentBotCfgs)
126163
b.botsLock.RLock()
127164
botsAlreadyInitialized = len(b.bots) > 0
128-
lastCfgs = b.lastEnsuredBotCfgs
165+
lastBotCfgs = b.lastEnsuredBotCfgs
166+
lastServiceCfgs = b.lastEnsuredServiceCfgs
129167
b.botsLock.RUnlock()
130168

131-
if botsAlreadyInitialized && botConfigsEqual(lastCfgs, currentBotCfgs) {
132-
b.pluginAPI.Log.Debug("EnsureBots: skipping after lock - bot configuration unchanged")
169+
if botsAlreadyInitialized && botConfigsEqual(lastBotCfgs, currentBotCfgs) && serviceConfigsEqual(lastServiceCfgs, currentServiceCfgs) {
170+
b.pluginAPI.Log.Debug("EnsureBots: skipping after lock - bot and service configuration unchanged")
133171
return nil
134172
}
135173

@@ -238,9 +276,16 @@ func (b *MMBots) EnsureBots() error {
238276

239277
b.botsLock.Lock()
240278
b.bots = bots
241-
// Store the successfully ensured bot configs for optimistic checking
242-
b.lastEnsuredBotCfgs = make([]llm.BotConfig, len(currentBotCfgs))
243-
copy(b.lastEnsuredBotCfgs, currentBotCfgs)
279+
// Store deep copies of the successfully ensured configs for optimistic checking.
280+
// Deep copy is needed because BotConfig contains slice fields (EnabledNativeTools, etc.)
281+
// that would otherwise share backing arrays with the live config.
282+
copiedBotCfgs, copyErr := config.DeepCopyJSON(currentBotCfgs)
283+
if copyErr != nil {
284+
b.botsLock.Unlock()
285+
return fmt.Errorf("failed to deep copy bot configs for change tracking: %w", copyErr)
286+
}
287+
b.lastEnsuredBotCfgs = copiedBotCfgs
288+
b.lastEnsuredServiceCfgs = currentServiceCfgs
244289
b.botsLock.Unlock()
245290

246291
return nil

0 commit comments

Comments
 (0)