brainiac-discord 0.0.15 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7091fddf5aa23196f3e031d6830549eca0463e29fad37433d4683a7c8cb816f7
4
- data.tar.gz: 1b1bf72a7180aa002c06a18774e65e36550fab21a0c7ec1dc02498454e1c9b2c
3
+ metadata.gz: 3b1fa3c52f3226376895626cbf3ba0d97963dcd8ea9f5ae227f4615797e48a6c
4
+ data.tar.gz: 99b890d0c8ccb29079b2617e0cd661aef8c703040b90db58a148938a73fc2990
5
5
  SHA512:
6
- metadata.gz: e1ed0e106262c3cd09c04d00cc918db60fa04c52651c45365daa2af1d1bf658e7ba619f8ecc60f3c841893802ceeb6c4fa18eca8f875bee5c0b3acc18cce26b5
7
- data.tar.gz: d124b1ed107451cf7c48e9815ba8e9fa049d891c91f78c000f7d2b46945053c7c710c29d15983c02ecf33cc73f0e0343942ee4ba777fa6c84bce474ae3a7456a
6
+ metadata.gz: 5754062ffb91909ec05451e84b3b03fca71754cbe9cb4434e154eb9e70df34654ffcd7c37c28be4adea593628944bc2d9b19a46d7d60fc08c15b7ebb40848520
7
+ data.tar.gz: ac00ebaa2b82042fb2fc7abbf3dcf94d1f674b0b9ded9a1e3fd7cd3974f0256ff0162440935acaa9b334e3eb2f081ed1393aaf2de87b99e61aa53fed8a0c4bcf
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "websocket-client-simple"
4
+ require "websocket"
4
5
 
5
6
  module Brainiac
6
7
  module Plugins
@@ -12,6 +13,15 @@ module Brainiac
12
13
  # and MESSAGE_REACTION_ADD events to handler functions.
13
14
  module Gateway
14
15
  GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"
16
+ GATEWAY_QUERY = "?v=10&encoding=json"
17
+
18
+ # Reconnect backoff (seconds). A normal disconnect starts at BASE and
19
+ # doubles up to CAP; a handshake rejection (HTTP non-101, e.g. Discord
20
+ # returning 429 to the WebSocket upgrade) starts higher to avoid a
21
+ # reconnect storm across many bots hammering the gateway in lockstep.
22
+ BACKOFF_BASE = 5
23
+ BACKOFF_HANDSHAKE_BASE = 30
24
+ BACKOFF_CAP = 300
15
25
 
16
26
  # Per-bot state: { agent_key => { token:, user_id:, status:, thread: } }
17
27
  @bots = {}
@@ -142,19 +152,80 @@ module Brainiac
142
152
 
143
153
  private
144
154
 
155
+ # Resolve the gateway URL to connect to, and honor Discord's session
156
+ # start limits. Queries GET /gateway/bot (authenticated) which returns
157
+ # the recommended WebSocket URL plus session_start_limit. If the bot
158
+ # has exhausted its identify allowance, sleep until the window resets
159
+ # before returning so we don't burn reconnects against a hard limit.
160
+ # Falls back to the hardcoded GATEWAY_URL if the call fails.
161
+ def resolve_gateway_url(bot_token, agent_display)
162
+ info = Api.request(:get, "/gateway/bot", token: bot_token, log_errors: false)
163
+ unless info.is_a?(Hash) && info["url"]
164
+ LOG.warn "[Discord:#{agent_display}] Could not fetch /gateway/bot, using default URL" if defined?(LOG)
165
+ return GATEWAY_URL
166
+ end
167
+
168
+ honor_session_limit(info["session_start_limit"], agent_display)
169
+
170
+ "#{info["url"]}/#{GATEWAY_QUERY}"
171
+ rescue StandardError => e
172
+ LOG.warn "[Discord:#{agent_display}] /gateway/bot lookup failed: #{e.message}, using default URL" if defined?(LOG)
173
+ GATEWAY_URL
174
+ end
175
+
176
+ # If Discord reports no remaining identifies, wait out the reset window.
177
+ def honor_session_limit(limit, agent_display)
178
+ return unless limit.is_a?(Hash)
179
+
180
+ remaining = limit["remaining"].to_i
181
+ return if remaining.positive?
182
+
183
+ reset_after_s = (limit["reset_after"].to_i / 1000.0).ceil
184
+ reset_after_s = 1 if reset_after_s <= 0
185
+ LOG.warn "[Discord:#{agent_display}] Session start limit exhausted, waiting #{reset_after_s}s" if defined?(LOG)
186
+ sleep reset_after_s
187
+ end
188
+
189
+ # Exponential backoff with full jitter, capped. `attempt` is 0-based.
190
+ def backoff_delay(attempt, base)
191
+ capped = [base * (2**attempt), BACKOFF_CAP].min
192
+ # Full jitter: random between base and the capped ceiling.
193
+ rand(base.to_f..capped.to_f).round(1)
194
+ end
195
+
145
196
  def start_gateway_for(agent_key, bot_token)
146
197
  thread = Thread.new do
147
198
  agent_display = agent_display_name(agent_key) || agent_key.capitalize
148
199
  bot_user_id = nil
200
+ attempt = 0
149
201
 
150
202
  loop do
151
203
  bot_user_id = run_gateway_connection(agent_key, agent_display, bot_token, bot_user_id)
204
+ # A clean return means the connection was established at least
205
+ # once; reset backoff so a later transient drop recovers fast.
206
+ attempt = 0
207
+ delay = backoff_delay(attempt, BACKOFF_BASE)
208
+ LOG.info "[Discord:#{agent_display}] Reconnecting in #{delay}s..." if defined?(LOG)
209
+ sleep delay
210
+ rescue WebSocket::Error::Handshake::InvalidStatusCode => e
211
+ @bots_mutex.synchronize do
212
+ @bots[agent_key][:status] = "error" if @bots[agent_key]
213
+ end
214
+ delay = backoff_delay(attempt, BACKOFF_HANDSHAKE_BASE)
215
+ attempt += 1
216
+ if defined?(LOG)
217
+ LOG.error "[Discord:#{agent_display}] Gateway handshake rejected (#{e.message}) — " \
218
+ "likely rate limited; backing off #{delay}s"
219
+ end
220
+ sleep delay
152
221
  rescue StandardError => e
153
222
  @bots_mutex.synchronize do
154
223
  @bots[agent_key][:status] = "error" if @bots[agent_key]
155
224
  end
156
- LOG.error "[Discord:#{agent_display}] Gateway error: #{e.message}, reconnecting in 5s..." if defined?(LOG)
157
- sleep 5
225
+ delay = backoff_delay(attempt, BACKOFF_BASE)
226
+ attempt += 1
227
+ LOG.error "[Discord:#{agent_display}] Gateway error: #{e.message}, reconnecting in #{delay}s..." if defined?(LOG)
228
+ sleep delay
158
229
  end
159
230
  end
160
231
  @bots_mutex.synchronize { @bots[agent_key][:thread] = thread if @bots[agent_key] }
@@ -172,10 +243,17 @@ module Brainiac
172
243
  bots[agent_key][:token] = bot_token
173
244
  end
174
245
 
175
- LOG.debug "[Discord:#{agent_display}] Connecting to Gateway..." if defined?(LOG) && LOG.respond_to?(:debug)
246
+ # The WebSocket handshake runs in websocket-client-simple's own reader
247
+ # thread, so a non-101 response surfaces via the :error callback rather
248
+ # than raising here. Capture it and re-raise after the connection wait
249
+ # so the reconnect loop can apply handshake-specific backoff.
250
+ handshake_error = nil
251
+
252
+ gateway_url = resolve_gateway_url(bot_token, agent_display)
253
+ LOG.debug "[Discord:#{agent_display}] Connecting to #{gateway_url}" if defined?(LOG) && LOG.respond_to?(:debug)
176
254
  heartbeat_thread = nil
177
255
  last_sequence = nil
178
- ws = WebSocket::Client::Simple.connect(GATEWAY_URL)
256
+ ws = WebSocket::Client::Simple.connect(gateway_url)
179
257
 
180
258
  ws.on :message do |msg|
181
259
  next if msg.data.nil? || msg.data.empty?
@@ -203,22 +281,40 @@ module Brainiac
203
281
  end
204
282
 
205
283
  ws.on :error do |e|
284
+ handshake_error = e if e.is_a?(WebSocket::Error::Handshake::InvalidStatusCode)
206
285
  LOG.error "[Discord:#{agent_display}] WebSocket error: #{e.message}" if defined?(LOG)
207
286
  end
208
287
 
209
- wait_for_disconnect(ws, agent_display)
288
+ wait_for_disconnect(ws, agent_display) { handshake_error }
289
+
290
+ # If the connection never opened because Discord rejected the upgrade,
291
+ # propagate so the caller backs off harder instead of tight-looping.
292
+ raise handshake_error if handshake_error && !ws.open?
293
+
210
294
  bot_user_id
211
295
  end
212
296
 
297
+ # Block until the socket closes. Returns promptly if a handshake error
298
+ # is reported (the socket never opens in that case). Backoff between
299
+ # reconnects is owned by the caller (start_gateway_for).
213
300
  def wait_for_disconnect(websocket, agent_display)
301
+ # Give the handshake a brief window to complete before deciding the
302
+ # socket is dead, so we don't treat a still-connecting socket as lost.
303
+ grace = 10
304
+ grace.times do
305
+ sleep 0.5
306
+ break if websocket.open? || (block_given? && yield)
307
+ end
308
+
214
309
  loop do
215
- sleep 1
216
- next if websocket.open?
310
+ return if block_given? && yield && !websocket.open?
217
311
 
218
- LOG.info "[Discord:#{agent_display}] Connection lost, reconnecting in 5s..." if defined?(LOG)
219
- sleep 5
220
- break
312
+ break unless websocket.open?
313
+
314
+ sleep 1
221
315
  end
316
+
317
+ LOG.info "[Discord:#{agent_display}] Connection lost" if defined?(LOG)
222
318
  end
223
319
 
224
320
  def handle_gateway_op(websocket, payload, agent_key, agent_display, bot_token, bot_user_id, heartbeat_thread, last_sequence)
@@ -61,6 +61,7 @@ module Brainiac
61
61
 
62
62
  reload_projects!
63
63
  reload_agent_registry!
64
+ reload_profiles! if defined?(reload_profiles!)
64
65
  Config.reload!
65
66
  return unless authorize_user(discord_user, discord_user_id, message, channel_id, message_id, agent_name, bot_token)
66
67
 
@@ -605,7 +606,9 @@ module Brainiac
605
606
  meta_file: meta_file, model: model, effort: effort, actual_resume: actual_resume,
606
607
  resolved: resolved, project_config: project_config,
607
608
  session_key: session_key, supersede_key: supersede_key,
608
- attachment_paths: attachment_paths, timestamp: timestamp, response_dir: response_dir
609
+ attachment_paths: attachment_paths, timestamp: timestamp, response_dir: response_dir,
610
+ profile: resolve_thread_profile(thread_map_key, clean_content),
611
+ explicit_model: explicit_model_tag?(clean_content, project_config, cli_provider_override: cli_provider_override)
609
612
  )
610
613
  end
611
614
 
@@ -710,6 +713,55 @@ module Brainiac
710
713
  "cli=#{cli_provider}, model=#{model}, effort=#{effort}"
711
714
  end
712
715
 
716
+ # Resolve the profile ([profile:X]/[p:X]) for this dispatch and make it sticky.
717
+ #
718
+ # An inline tag wins and is saved on the thread map entry (and the core work item),
719
+ # so later untagged replies in the same thread keep running on that profile until a
720
+ # different [p:X] replaces it. Without a tag, the stored thread profile is used, then
721
+ # the work item's (e.g. set from a Fizzy card). nil means the default profile.
722
+ # Unknown profile names are passed through (core ignores them with a warning) but
723
+ # never saved, so a typo can't pin the thread to a nonexistent profile.
724
+ def resolve_thread_profile(thread_map_key, clean_content)
725
+ inline = parse_inline_tags(clean_content)[:profile]
726
+ return inline unless thread_map_key
727
+
728
+ if inline
729
+ persist_thread_profile(thread_map_key, inline) if !defined?(profile_entry) || profile_entry(inline)
730
+ return inline
731
+ end
732
+
733
+ entry = Config.thread_map_mutex.synchronize { Config.load_thread_map[thread_map_key] }
734
+ return nil unless entry
735
+
736
+ entry["profile"] || work_item_profile_for(entry["branch"])
737
+ end
738
+
739
+ def persist_thread_profile(thread_map_key, profile)
740
+ branch = Config.thread_map_mutex.synchronize do
741
+ map = Config.load_thread_map
742
+ entry = map[thread_map_key]
743
+ next nil unless entry
744
+
745
+ if entry["profile"] != profile
746
+ entry["profile"] = profile
747
+ Config.save_thread_map(map)
748
+ LOG.info "[Discord] Persisted profile for #{thread_map_key}: #{profile}" if defined?(LOG)
749
+ end
750
+ entry["branch"]
751
+ end
752
+
753
+ return unless branch && defined?(update_work_item_overrides) &&
754
+ method(:update_work_item_overrides).parameters.any? { |t, n| t == :key && n == :profile }
755
+
756
+ update_work_item_overrides(branch: branch, profile: profile)
757
+ end
758
+
759
+ def work_item_profile_for(branch)
760
+ return nil unless branch && defined?(work_item_overrides_for)
761
+
762
+ work_item_overrides_for(branch: branch)["profile"]
763
+ end
764
+
713
765
  def explicit_model_tag?(clean_content, project_config, cli_provider_override: nil)
714
766
  return false unless project_config
715
767
 
@@ -869,6 +921,8 @@ module Brainiac
869
921
  "model" => model, "effort" => effort, "created_at" => Time.now.iso8601 }
870
922
  entry["branch"] = branch if branch
871
923
  entry["chat_mode"] = true if chat_mode
924
+ # Keep a sticky profile when an entry is rebuilt (e.g. its worktree was recreated).
925
+ entry["profile"] = map.dig(thread_map_key, "profile") if map.dig(thread_map_key, "profile")
872
926
  map[thread_map_key] = entry
873
927
  Config.save_thread_map(map)
874
928
  end
@@ -904,7 +958,8 @@ module Brainiac
904
958
  def spawn_agent(agent_key:, agent_name:, bot_token:, channel_id:, message_id:, discord_user:,
905
959
  work_dir:, prompt_file:, response_file:, meta_file:, model:, effort:,
906
960
  actual_resume:, resolved:, project_config:,
907
- session_key:, supersede_key:, attachment_paths:, timestamp:, response_dir:)
961
+ session_key:, supersede_key:, attachment_paths:, timestamp:, response_dir:, profile: nil,
962
+ explicit_model: nil)
908
963
  agent_config_name = agent_key.downcase.gsub(/[^a-z0-9-]/, "-")
909
964
  log_file = File.join(response_dir, "discord-agent-#{timestamp}-#{agent_key}-#{message_id}.log")
910
965
 
@@ -920,6 +975,14 @@ module Brainiac
920
975
 
921
976
  spawn_env = {}
922
977
  agent_env = agent_env_for(agent_name)
978
+ # Profile env (named bundle, e.g. an alternate kiro-cli account via
979
+ # XDG_DATA_HOME). An explicitly-requested [profile:X] wins over agent
980
+ # env; the default profile is only a fallback. See core profiles.rb.
981
+ agent_env = profile_spawn_env(agent_env, profile) if defined?(profile_spawn_env)
982
+ # The *effective* profile name that actually ran (explicit if valid,
983
+ # else the default profile) — recorded in durable history so the
984
+ # monitor can show which account/profile the session used.
985
+ effective_profile = defined?(effective_profile_name) ? effective_profile_name(profile) : profile
923
986
  unless agent_env.empty?
924
987
  spawn_env.merge!(agent_env)
925
988
  LOG.info "[Discord:#{agent_name}] Injecting #{agent_env.size} env var(s): #{agent_env.keys.join(", ")}" if defined?(LOG)
@@ -936,6 +999,22 @@ module Brainiac
936
999
  LOG.info "[Discord:#{agent_name}] Injecting GitHub App token (GH_TOKEN) for bot identity" if defined?(LOG)
937
1000
  end
938
1001
 
1002
+ # For providers with no runtime --model flag (e.g. kiro-cli, model_flag: ""),
1003
+ # persist the model to the provider's settings store before spawning. Discord
1004
+ # dispatches its own agents and never calls core's run_agent, so we invoke the
1005
+ # same helper here — running it under the fully-assembled spawn_env so the write
1006
+ # targets the correct account's KIRO_HOME. No-op when a model_flag is configured.
1007
+ # explicit_model tells the helper whether this came from an inline tag: an explicit
1008
+ # [auto] writes (a deliberate reset), a defaulted "auto" does not. Guard the kwarg
1009
+ # against older core builds whose apply_settings_model has no `explicit:` param.
1010
+ if defined?(apply_settings_model)
1011
+ if method(:apply_settings_model).parameters.any? { |t, n| t == :key && n == :explicit }
1012
+ apply_settings_model(model, resolved, spawn_env, explicit: explicit_model)
1013
+ else
1014
+ apply_settings_model(model, resolved, spawn_env)
1015
+ end
1016
+ end
1017
+
939
1018
  head_before, status_before = capture_brainiac_state(project_config, work_dir)
940
1019
  prompt_mode = resolved["prompt_mode"] || "stdin"
941
1020
 
@@ -960,7 +1039,8 @@ module Brainiac
960
1039
  attachment_paths: attachment_paths, project_config: project_config,
961
1040
  head_before: head_before, status_before: status_before,
962
1041
  supersede_key: supersede_key,
963
- resolved: resolved, model: model, work_dir: work_dir, started_at: started_at
1042
+ resolved: resolved, model: model, work_dir: work_dir, started_at: started_at,
1043
+ profile: effective_profile
964
1044
  )
965
1045
  end
966
1046
 
@@ -1139,7 +1219,7 @@ module Brainiac
1139
1219
  def monitor_agent(pid:, session_key:, agent_name:, agent_config_name:, channel_id:, message_id:,
1140
1220
  bot_token:, response_file:, meta_file:, prompt_file:, log_file:,
1141
1221
  attachment_paths:, project_config:, head_before:, status_before:, supersede_key: nil,
1142
- resolved: nil, model: nil, work_dir: nil, started_at: nil)
1222
+ resolved: nil, model: nil, work_dir: nil, started_at: nil, profile: nil)
1143
1223
  Thread.new do
1144
1224
  Process.wait(pid)
1145
1225
  exit_status = $CHILD_STATUS
@@ -1156,7 +1236,7 @@ module Brainiac
1156
1236
  agent_name: agent_name, agent_config_name: agent_config_name,
1157
1237
  channel_id: channel_id, message_id: message_id, log_file: log_file,
1158
1238
  resolved: resolved, model: model, work_dir: work_dir, started_at: started_at,
1159
- exit_status: exit_status
1239
+ exit_status: exit_status, profile: profile
1160
1240
  )
1161
1241
 
1162
1242
  if exit_status.signaled? || session_cancelled
@@ -1199,7 +1279,8 @@ module Brainiac
1199
1279
  # Fully best-effort: archive_session_history already rescues internally, but we
1200
1280
  # guard here too so a history failure can never break Discord's completion path.
1201
1281
  def archive_discord_session_history(agent_name:, agent_config_name:, channel_id:, message_id:,
1202
- log_file:, resolved:, model:, work_dir:, started_at:, exit_status:)
1282
+ log_file:, resolved:, model:, work_dir:, started_at:, exit_status:,
1283
+ profile: nil)
1203
1284
  return unless defined?(archive_session_history)
1204
1285
 
1205
1286
  ctx = {
@@ -1213,6 +1294,7 @@ module Brainiac
1213
1294
  chdir: work_dir,
1214
1295
  agent_cli: resolved && resolved["agent_cli"],
1215
1296
  model: model,
1297
+ profile: profile,
1216
1298
  started_at: started_at
1217
1299
  }
1218
1300
 
@@ -3,7 +3,7 @@
3
3
  module Brainiac
4
4
  module Plugins
5
5
  module Discord
6
- VERSION = "0.0.15"
6
+ VERSION = "0.0.17"
7
7
  end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brainiac-discord
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.15
4
+ version: 0.0.17
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis