brainiac-discord 0.0.16 → 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: afab8518a3314e7873e2bec99ba79f7efeedf1b45b80be5ccc9d0654d0b64b77
4
- data.tar.gz: a39348b184343d375313e1ca30e746165690562a729bac532e9880af4a9b540c
3
+ metadata.gz: 3b1fa3c52f3226376895626cbf3ba0d97963dcd8ea9f5ae227f4615797e48a6c
4
+ data.tar.gz: 99b890d0c8ccb29079b2617e0cd661aef8c703040b90db58a148938a73fc2990
5
5
  SHA512:
6
- metadata.gz: aecb0bff2e82096ba8ec67c7a862a9316025030fa999d87c323f023a95ab463807c0dcf1eac5b7055b1d6307f346acf80fd08ae0805bf21c7e248fc29e3f35b5
7
- data.tar.gz: aeb1f966c703cbf553d50535dcf6768ef1af4fc38443c47b0e0d4544dd11c42d5cf31049ccc86a7a8d6efdfd7e256e9f02c898cac9792ebc82732c243ba4692b
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)
@@ -607,7 +607,8 @@ module Brainiac
607
607
  resolved: resolved, project_config: project_config,
608
608
  session_key: session_key, supersede_key: supersede_key,
609
609
  attachment_paths: attachment_paths, timestamp: timestamp, response_dir: response_dir,
610
- profile: parse_inline_tags(clean_content)[:profile]
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)
611
612
  )
612
613
  end
613
614
 
@@ -712,6 +713,55 @@ module Brainiac
712
713
  "cli=#{cli_provider}, model=#{model}, effort=#{effort}"
713
714
  end
714
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
+
715
765
  def explicit_model_tag?(clean_content, project_config, cli_provider_override: nil)
716
766
  return false unless project_config
717
767
 
@@ -871,6 +921,8 @@ module Brainiac
871
921
  "model" => model, "effort" => effort, "created_at" => Time.now.iso8601 }
872
922
  entry["branch"] = branch if branch
873
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")
874
926
  map[thread_map_key] = entry
875
927
  Config.save_thread_map(map)
876
928
  end
@@ -906,7 +958,8 @@ module Brainiac
906
958
  def spawn_agent(agent_key:, agent_name:, bot_token:, channel_id:, message_id:, discord_user:,
907
959
  work_dir:, prompt_file:, response_file:, meta_file:, model:, effort:,
908
960
  actual_resume:, resolved:, project_config:,
909
- session_key:, supersede_key:, attachment_paths:, timestamp:, response_dir:, profile: nil)
961
+ session_key:, supersede_key:, attachment_paths:, timestamp:, response_dir:, profile: nil,
962
+ explicit_model: nil)
910
963
  agent_config_name = agent_key.downcase.gsub(/[^a-z0-9-]/, "-")
911
964
  log_file = File.join(response_dir, "discord-agent-#{timestamp}-#{agent_key}-#{message_id}.log")
912
965
 
@@ -926,6 +979,10 @@ module Brainiac
926
979
  # XDG_DATA_HOME). An explicitly-requested [profile:X] wins over agent
927
980
  # env; the default profile is only a fallback. See core profiles.rb.
928
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
929
986
  unless agent_env.empty?
930
987
  spawn_env.merge!(agent_env)
931
988
  LOG.info "[Discord:#{agent_name}] Injecting #{agent_env.size} env var(s): #{agent_env.keys.join(", ")}" if defined?(LOG)
@@ -942,6 +999,22 @@ module Brainiac
942
999
  LOG.info "[Discord:#{agent_name}] Injecting GitHub App token (GH_TOKEN) for bot identity" if defined?(LOG)
943
1000
  end
944
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
+
945
1018
  head_before, status_before = capture_brainiac_state(project_config, work_dir)
946
1019
  prompt_mode = resolved["prompt_mode"] || "stdin"
947
1020
 
@@ -966,7 +1039,8 @@ module Brainiac
966
1039
  attachment_paths: attachment_paths, project_config: project_config,
967
1040
  head_before: head_before, status_before: status_before,
968
1041
  supersede_key: supersede_key,
969
- 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
970
1044
  )
971
1045
  end
972
1046
 
@@ -1145,7 +1219,7 @@ module Brainiac
1145
1219
  def monitor_agent(pid:, session_key:, agent_name:, agent_config_name:, channel_id:, message_id:,
1146
1220
  bot_token:, response_file:, meta_file:, prompt_file:, log_file:,
1147
1221
  attachment_paths:, project_config:, head_before:, status_before:, supersede_key: nil,
1148
- resolved: nil, model: nil, work_dir: nil, started_at: nil)
1222
+ resolved: nil, model: nil, work_dir: nil, started_at: nil, profile: nil)
1149
1223
  Thread.new do
1150
1224
  Process.wait(pid)
1151
1225
  exit_status = $CHILD_STATUS
@@ -1162,7 +1236,7 @@ module Brainiac
1162
1236
  agent_name: agent_name, agent_config_name: agent_config_name,
1163
1237
  channel_id: channel_id, message_id: message_id, log_file: log_file,
1164
1238
  resolved: resolved, model: model, work_dir: work_dir, started_at: started_at,
1165
- exit_status: exit_status
1239
+ exit_status: exit_status, profile: profile
1166
1240
  )
1167
1241
 
1168
1242
  if exit_status.signaled? || session_cancelled
@@ -1205,7 +1279,8 @@ module Brainiac
1205
1279
  # Fully best-effort: archive_session_history already rescues internally, but we
1206
1280
  # guard here too so a history failure can never break Discord's completion path.
1207
1281
  def archive_discord_session_history(agent_name:, agent_config_name:, channel_id:, message_id:,
1208
- log_file:, resolved:, model:, work_dir:, started_at:, exit_status:)
1282
+ log_file:, resolved:, model:, work_dir:, started_at:, exit_status:,
1283
+ profile: nil)
1209
1284
  return unless defined?(archive_session_history)
1210
1285
 
1211
1286
  ctx = {
@@ -1219,6 +1294,7 @@ module Brainiac
1219
1294
  chdir: work_dir,
1220
1295
  agent_cli: resolved && resolved["agent_cli"],
1221
1296
  model: model,
1297
+ profile: profile,
1222
1298
  started_at: started_at
1223
1299
  }
1224
1300
 
@@ -3,7 +3,7 @@
3
3
  module Brainiac
4
4
  module Plugins
5
5
  module Discord
6
- VERSION = "0.0.16"
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.16
4
+ version: 0.0.17
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis