brainiac-basecamp 0.0.7 → 0.0.9

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: 6bffdae0c7f62eef1cf55ee5c2c23fb8594dd63afc96ffee5cf3c58cdca9ce9f
4
- data.tar.gz: 3b61e5ae5ef532935c07603c55f6fa36000796ed55951e8277205c219306600b
3
+ metadata.gz: f3b4d7d9e99a700f2a533b48110071025bde9eb69b55633acffd4125f88c5540
4
+ data.tar.gz: 63635c64597b65ed6e0a69e90511010d6975c418249c7a181d9a0349bded7dcc
5
5
  SHA512:
6
- metadata.gz: 6eaa711a5cd134e1838335dbf31813d44ac66b6bb596d731a8c89dde9f99ecc8bca8aea65054fa166da8f40f16cec5df1509719fc201bc82bdeb55fce99abca5
7
- data.tar.gz: d60e2e46e81dd1a1798127583e6be1620fcd136c482e369fd7b7c129f5a5074cf88edf2cb2b6f6d7bd3a258372feea0886834cfabcc421081f872c89e9bd96a5
6
+ metadata.gz: 37d7d00ab786c8f99318d7098b8a700d8428d0c3db1da4ceaa1040aa924b627b0b886a928cb7f6f1661e35f578403ae4b8911b87a5c48c6bc67a9c0ed5f61c02
7
+ data.tar.gz: a798eb76376698539c16c54ecdfea9d597893e0f0630c035e9e473401e45b43c0357e4cb6ef8717df704c98543f2bace4754b5f2db6dc97677a46ee8782b2ff9
@@ -203,6 +203,74 @@ module Brainiac
203
203
  dispatched
204
204
  end
205
205
 
206
+ # Dispatch only gate agents that have NOT yet responded (no approval, no changes_requested).
207
+ # Used during resume/health-check when some gates responded but others didn't.
208
+ #
209
+ # Tracks per-gate re-dispatch count to prevent infinite loops when a gate agent
210
+ # keeps crashing. After MAX_GATE_REDISPATCH_RETRIES, stops retrying that gate.
211
+ #
212
+ # IMPORTANT: Does NOT overwrite gates_dispatched_at — that timestamp represents
213
+ # the original dispatch and is used for stale detection. Overwriting it would
214
+ # reset the timeout window on every partial re-dispatch, causing infinite loops.
215
+ #
216
+ # @param epic [Hash] Epic state
217
+ # @param task [Hash] Task state
218
+ # @param pr_number [Integer, String] PR number
219
+ # @param repo_name [String] e.g. "stowzilla/brainiac-basecamp"
220
+ # @param repo_path [String] Local repo path
221
+ # @return [Array<String>] Agent names dispatched
222
+ def dispatch_missing_gates(epic:, task:, pr_number:, repo_name:, repo_path:)
223
+ responded_agents = Set.new
224
+ (task["gate_approvals"] || []).each { |a| responded_agents << a["agent"].downcase }
225
+ (task["changes_requested_by"] || []).each { |a| responded_agents << a.downcase }
226
+
227
+ missing_gates = gates.reject { |g| responded_agents.include?(g["agent"].downcase) }
228
+ return [] if missing_gates.empty?
229
+
230
+ # Track per-gate re-dispatch counts to prevent infinite loops
231
+ task["gate_redispatch_counts"] ||= {}
232
+
233
+ dispatched = []
234
+
235
+ missing_gates.each do |gate|
236
+ agent_name = gate["agent"]
237
+ role = gate["role"] || "review"
238
+
239
+ # Check retry count — stop if we've exceeded the max
240
+ retries = task["gate_redispatch_counts"][agent_name.downcase] || 0
241
+ if retries >= MAX_GATE_REDISPATCH_RETRIES
242
+ LOG.warn "[Basecamp:ReviewGate] Gate #{agent_name} exceeded max re-dispatch retries (#{retries}/#{MAX_GATE_REDISPATCH_RETRIES}) — skipping" if defined?(LOG)
243
+ next
244
+ end
245
+
246
+ LOG.info "[Basecamp:ReviewGate] Re-dispatching missing gate #{agent_name} (#{role}) to review PR ##{pr_number} (retry #{retries + 1}/#{MAX_GATE_REDISPATCH_RETRIES})" if defined?(LOG)
247
+
248
+ task["gate_redispatch_counts"][agent_name.downcase] = retries + 1
249
+
250
+ Thread.new do
251
+ dispatch_agent_for_review(
252
+ agent_name: agent_name,
253
+ role: role,
254
+ pr_number: pr_number,
255
+ repo_name: repo_name,
256
+ repo_path: repo_path,
257
+ card_number: task["fizzy_card"],
258
+ epic: epic
259
+ )
260
+ rescue StandardError => e
261
+ LOG.error "[Basecamp:ReviewGate] Failed to dispatch #{agent_name}: #{e.message}" if defined?(LOG)
262
+ end
263
+
264
+ dispatched << agent_name
265
+ end
266
+
267
+ # Record when this partial re-dispatch happened (for diagnostics)
268
+ # but do NOT overwrite gates_dispatched_at — that's the original timestamp
269
+ task["last_redispatch_at"] = Time.now.iso8601
270
+
271
+ dispatched
272
+ end
273
+
206
274
  # Build the summary comment for Fizzy after all gates pass and merge completes.
207
275
  #
208
276
  # @param task [Hash] Task state
@@ -3,7 +3,7 @@
3
3
  module Brainiac
4
4
  module Plugins
5
5
  module Basecamp
6
- VERSION = "0.0.7"
6
+ VERSION = "0.0.9"
7
7
  end
8
8
  end
9
9
  end
@@ -16,6 +16,15 @@ require_relative "basecamp/cli"
16
16
  module Brainiac
17
17
  module Plugins
18
18
  module Basecamp
19
+ # Maximum seconds to wait for a dispatched agent/gate to respond before
20
+ # considering it stale and re-dispatching. Used across resume, health-check,
21
+ # and dispatch_missing_gates.
22
+ STALE_DISPATCH_TIMEOUT = 300 # 5 minutes
23
+
24
+ # Maximum number of times a gate will be re-dispatched for the same review
25
+ # cycle before giving up (prevents infinite re-dispatch loops).
26
+ MAX_GATE_REDISPATCH_RETRIES = 3
27
+
19
28
  class << self
20
29
  # Called by Brainiac plugin system during server startup.
21
30
  #
@@ -107,22 +116,38 @@ module Brainiac
107
116
  end
108
117
 
109
118
  when "in_flight"
110
- # Check if impl agent is assigned when changes were requested
111
- if task["changes_requested_by"]&.any?
112
- impl_agent = epic["agent"]
113
- card_json, = Open3.capture2("fizzy", "card", "show", card_number.to_s, "--json")
114
- card_data = JSON.parse(card_json) rescue {}
115
- assignees = card_data.dig("data", "assignees") || []
116
- assignee_names = assignees.map { |a| a["name"]&.downcase }
117
-
118
- unless assignee_names.include?(impl_agent.downcase)
119
- LOG.info "[Basecamp:HealthCheck] Task ##{card_number} in_flight with changes_requested but #{impl_agent} not assigned — healing" if defined?(LOG)
119
+ # Check if impl agent session is likely dead after a restart.
120
+ # Instead of just checking Fizzy assignment (stale artifact), use
121
+ # dispatched_at as a liveness signal — if dispatched too long ago
122
+ # without completion, assume the session died and re-dispatch.
123
+ dispatched_at = task["dispatched_at"]
124
+ if dispatched_at
125
+ elapsed = Time.now - Time.parse(dispatched_at)
126
+ # If task has been in_flight for longer than the stale timeout
127
+ # AND no agent is actively running (we can't check PID, so use elapsed time)
128
+ if elapsed > STALE_DISPATCH_TIMEOUT
129
+ impl_agent = epic["agent"]
130
+ LOG.info "[Basecamp:HealthCheck] Task ##{card_number} in_flight for #{elapsed.round}s — re-dispatching #{impl_agent}" if defined?(LOG)
131
+
132
+ # Re-dispatch by re-assigning the card (triggers webhook)
133
+ task["dispatched_at"] = Time.now.iso8601
120
134
  fizzy_user_id = Orchestrator.send(:resolve_fizzy_user_id, impl_agent)
121
135
  if fizzy_user_id
122
- Open3.capture3("fizzy", "card", "assign", card_number.to_s, "--user", fizzy_user_id)
136
+ Hooks.send(:safe_assign_card, card_number, fizzy_user_id)
123
137
  healed_any = true
124
138
  end
125
139
  end
140
+ elsif task["changes_requested_by"]&.any?
141
+ # No dispatched_at recorded but changes were requested — legacy state.
142
+ # Always re-dispatch in this case.
143
+ impl_agent = epic["agent"]
144
+ LOG.info "[Basecamp:HealthCheck] Task ##{card_number} in_flight with changes_requested but no dispatched_at — re-dispatching #{impl_agent}" if defined?(LOG)
145
+ task["dispatched_at"] = Time.now.iso8601
146
+ fizzy_user_id = Orchestrator.send(:resolve_fizzy_user_id, impl_agent)
147
+ if fizzy_user_id
148
+ Hooks.send(:safe_assign_card, card_number, fizzy_user_id)
149
+ healed_any = true
150
+ end
126
151
  end
127
152
 
128
153
  when "in_review"
@@ -168,7 +193,7 @@ module Brainiac
168
193
  elsif (task["gate_approvals"] || []).empty? && !task["changes_requested_by"]&.any?
169
194
  # No gate responses at all — gates may never have been dispatched or agents crashed
170
195
  gates_dispatched_at = task["gates_dispatched_at"]
171
- should_redispatch = gates_dispatched_at.nil? || (Time.now - Time.parse(gates_dispatched_at)) > 300
196
+ should_redispatch = gates_dispatched_at.nil? || (Time.now - Time.parse(gates_dispatched_at)) > STALE_DISPATCH_TIMEOUT
172
197
 
173
198
  if should_redispatch && github_repo
174
199
  LOG.info "[Basecamp:HealthCheck] No gate responses for ##{card_number} — re-dispatching review gates" if defined?(LOG)
@@ -182,6 +207,25 @@ module Brainiac
182
207
  Hooks.send(:save_epic_state, epic)
183
208
  healed_any = true
184
209
  end
210
+ else
211
+ # Partial responses — some gates responded but others haven't
212
+ gates_dispatched_at = task["gates_dispatched_at"]
213
+ if gates_dispatched_at && (Time.now - Time.parse(gates_dispatched_at)) > STALE_DISPATCH_TIMEOUT
214
+ responded_count = (task["gate_approvals"]&.size || 0) + (task["changes_requested_by"]&.size || 0)
215
+ total_gates = ReviewGate.gates.size
216
+ if responded_count.positive? && responded_count < total_gates && github_repo
217
+ LOG.info "[Basecamp:HealthCheck] Partial gate response for ##{card_number} (#{responded_count}/#{total_gates}) — re-dispatching missing gates" if defined?(LOG)
218
+ ReviewGate.dispatch_missing_gates(
219
+ epic: epic,
220
+ task: task,
221
+ pr_number: pr_number,
222
+ repo_name: github_repo,
223
+ repo_path: repo_path
224
+ )
225
+ Hooks.send(:save_epic_state, epic)
226
+ healed_any = true
227
+ end
228
+ end
185
229
  end
186
230
  end
187
231
  end
@@ -278,61 +322,70 @@ module Brainiac
278
322
  Hooks.send(:save_epic_state, epic)
279
323
  Hooks.send(:dispatch_final_decision, epic, task, {})
280
324
  elsif task["changes_requested_by"]&.any?
281
- # Gates requested changes — check if impl agent is assigned
325
+ # Gates requested changes — after a restart, no agent session is running even if assigned.
326
+ # Always re-dispatch the impl agent to address the feedback.
282
327
  impl_agent = epic["agent"]
283
- LOG.info "[Basecamp] Resume: ##{card_number} has changes requested, checking assignment" if defined?(LOG)
328
+ LOG.info "[Basecamp] Resume: ##{card_number} has changes requested re-dispatching #{impl_agent}" if defined?(LOG)
284
329
 
285
- # Check current assignees via Fizzy CLI
286
- card_json, = Open3.capture2("fizzy", "card", "show", card_number.to_s, "--json")
287
- card_data = JSON.parse(card_json) rescue {}
288
- assignees = card_data.dig("data", "assignees") || []
289
- assignee_names = assignees.map { |a| a["name"]&.downcase }
330
+ # Transition to in_flight so the agent gets the right context
331
+ task["status"] = "in_flight"
332
+ Hooks.send(:save_epic_state, epic)
290
333
 
291
- if assignee_names.include?(impl_agent.downcase)
292
- LOG.info "[Basecamp] Resume: ##{card_number} already assigned to #{impl_agent} — waiting for fixes" if defined?(LOG)
293
- else
294
- # Re-assign to trigger dispatch
295
- LOG.info "[Basecamp] Resume: ##{card_number} not assigned to #{impl_agent} — re-assigning" if defined?(LOG)
296
- fizzy_user_id = Orchestrator.send(:resolve_fizzy_user_id, impl_agent)
297
- if fizzy_user_id
298
- Open3.capture3("fizzy", "card", "assign", card_number.to_s, "--user", fizzy_user_id)
299
- end
334
+ # Re-assign card to trigger a fresh dispatch via the Fizzy webhook
335
+ fizzy_user_id = Orchestrator.send(:resolve_fizzy_user_id, impl_agent)
336
+ if fizzy_user_id
337
+ Hooks.send(:safe_assign_card, card_number, fizzy_user_id)
300
338
  end
301
339
  else
302
340
  # No changes requested yet — check if gates need (re-)dispatching
303
341
  approvals = task["gate_approvals"]&.size || 0
304
342
  gates_dispatched_at = task["gates_dispatched_at"]
305
343
 
344
+ # Calculate how many gates have responded (approved or requested changes)
345
+ responded_count = approvals + (task["changes_requested_by"]&.size || 0)
346
+ total_gates = ReviewGate.gates.size
347
+
306
348
  # Re-dispatch gates if:
307
349
  # 1. Gates were never dispatched (missed webhook), OR
308
- # 2. Gates were dispatched but no responses received after a reasonable window
350
+ # 2. Gates were dispatched but no responses received after 5 minutes, OR
351
+ # 3. Some gates responded but others didn't after 5 minutes (partial response)
309
352
  should_redispatch = if gates_dispatched_at.nil?
310
353
  true
311
- elsif approvals.zero?
312
- # If dispatched more than 5 minutes ago with no responses, re-dispatch
313
- elapsed = Time.now - Time.parse(gates_dispatched_at)
314
- elapsed > 300
315
354
  else
316
- false
355
+ elapsed = Time.now - Time.parse(gates_dispatched_at)
356
+ elapsed > STALE_DISPATCH_TIMEOUT && responded_count < total_gates
317
357
  end
318
358
 
319
359
  if should_redispatch && ReviewGate.enabled?
320
360
  github_repo = projects.dig(project_key, "github_repo")
321
361
  if github_repo
322
- LOG.info "[Basecamp] Resume: ##{card_number} has #{approvals} approvals — re-dispatching review gates" if defined?(LOG)
323
- ReviewGate.dispatch_gates(
324
- epic: epic,
325
- task: task,
326
- pr_number: pr_number,
327
- repo_name: github_repo,
328
- repo_path: repo_path
329
- )
362
+ if responded_count.zero?
363
+ # No responses at all — dispatch all gates
364
+ LOG.info "[Basecamp] Resume: ##{card_number} has 0/#{total_gates} gate responses — re-dispatching all review gates" if defined?(LOG)
365
+ ReviewGate.dispatch_gates(
366
+ epic: epic,
367
+ task: task,
368
+ pr_number: pr_number,
369
+ repo_name: github_repo,
370
+ repo_path: repo_path
371
+ )
372
+ else
373
+ # Partial responses — dispatch only the missing gates
374
+ LOG.info "[Basecamp] Resume: ##{card_number} has #{responded_count}/#{total_gates} gate responses — re-dispatching missing gates" if defined?(LOG)
375
+ ReviewGate.dispatch_missing_gates(
376
+ epic: epic,
377
+ task: task,
378
+ pr_number: pr_number,
379
+ repo_name: github_repo,
380
+ repo_path: repo_path
381
+ )
382
+ end
330
383
  Hooks.send(:save_epic_state, epic)
331
384
  else
332
385
  LOG.warn "[Basecamp] Resume: ##{card_number} — cannot re-dispatch gates, no github_repo for #{project_key}" if defined?(LOG)
333
386
  end
334
387
  else
335
- LOG.info "[Basecamp] Resume: ##{card_number} has #{approvals} approvals, waiting for more reviews" if defined?(LOG)
388
+ LOG.info "[Basecamp] Resume: ##{card_number} has #{responded_count}/#{total_gates} gate responses, waiting for more reviews" if defined?(LOG)
336
389
  end
337
390
  end
338
391
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brainiac-basecamp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.7
4
+ version: 0.0.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis