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
6064func 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.
7494func 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
102136func (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