brainiac-basecamp 0.0.6 → 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: a07338e5677f448dd7b2fb15bb345009532fef8669fc375526738a8cf0eaf24d
4
- data.tar.gz: 8d2aaabe74e5d02a770a3f38366287362aeab07dea91954f3ca54e9ae71ef17d
3
+ metadata.gz: f3b4d7d9e99a700f2a533b48110071025bde9eb69b55633acffd4125f88c5540
4
+ data.tar.gz: 63635c64597b65ed6e0a69e90511010d6975c418249c7a181d9a0349bded7dcc
5
5
  SHA512:
6
- metadata.gz: a217e3701939b30771f21eb8696662052eb8abdb99ae78936a9f27b14044c263aff7a3291cf51b04467d24f1c4b4cab993945503b5ca8e63f004348e8ae0409d
7
- data.tar.gz: 939aeb9b76e0e38bdb2132ea3f5e0ad14096600bab0a1cb844425ec2dd101c8222d79a20da29e2c31abfe850134eb2725b8d9b5820ddd26544d777852f6f0508
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.6"
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"
@@ -131,10 +156,11 @@ module Brainiac
131
156
  projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
132
157
  projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
133
158
  repo_path = projects.dig(project_key, "repo_path")
159
+ github_repo = projects.dig(project_key, "github_repo")
134
160
 
135
161
  if repo_path
136
162
  # Sync from GitHub to catch any missed approvals
137
- sync_result = ReviewGate.sync_from_github(task, repo_path: repo_path)
163
+ ReviewGate.sync_from_github(task, repo_path: repo_path)
138
164
 
139
165
  if ReviewGate.all_gates_passed?(task)
140
166
  # All approved — advance to final_decision
@@ -164,6 +190,42 @@ module Brainiac
164
190
  end
165
191
  end
166
192
  healed_any = true
193
+ elsif (task["gate_approvals"] || []).empty? && !task["changes_requested_by"]&.any?
194
+ # No gate responses at all — gates may never have been dispatched or agents crashed
195
+ gates_dispatched_at = task["gates_dispatched_at"]
196
+ should_redispatch = gates_dispatched_at.nil? || (Time.now - Time.parse(gates_dispatched_at)) > STALE_DISPATCH_TIMEOUT
197
+
198
+ if should_redispatch && github_repo
199
+ LOG.info "[Basecamp:HealthCheck] No gate responses for ##{card_number} — re-dispatching review gates" if defined?(LOG)
200
+ ReviewGate.dispatch_gates(
201
+ epic: epic,
202
+ task: task,
203
+ pr_number: pr_number,
204
+ repo_name: github_repo,
205
+ repo_path: repo_path
206
+ )
207
+ Hooks.send(:save_epic_state, epic)
208
+ healed_any = true
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
167
229
  end
168
230
  end
169
231
  end
@@ -260,30 +322,71 @@ module Brainiac
260
322
  Hooks.send(:save_epic_state, epic)
261
323
  Hooks.send(:dispatch_final_decision, epic, task, {})
262
324
  elsif task["changes_requested_by"]&.any?
263
- # 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.
264
327
  impl_agent = epic["agent"]
265
- 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)
266
329
 
267
- # Check current assignees via Fizzy CLI
268
- card_json, = Open3.capture2("fizzy", "card", "show", card_number.to_s, "--json")
269
- card_data = JSON.parse(card_json) rescue {}
270
- assignees = card_data.dig("data", "assignees") || []
271
- 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)
272
333
 
273
- if assignee_names.include?(impl_agent.downcase)
274
- LOG.info "[Basecamp] Resume: ##{card_number} already assigned to #{impl_agent} — waiting for fixes" if defined?(LOG)
275
- else
276
- # Re-assign to trigger dispatch
277
- LOG.info "[Basecamp] Resume: ##{card_number} not assigned to #{impl_agent} — re-assigning" if defined?(LOG)
278
- fizzy_user_id = Orchestrator.send(:resolve_fizzy_user_id, impl_agent)
279
- if fizzy_user_id
280
- Open3.capture3("fizzy", "card", "assign", card_number.to_s, "--user", fizzy_user_id)
281
- 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)
282
338
  end
283
339
  else
284
- # No changes requested yet — just waiting for reviews
340
+ # No changes requested yet — check if gates need (re-)dispatching
285
341
  approvals = task["gate_approvals"]&.size || 0
286
- LOG.info "[Basecamp] Resume: ##{card_number} has #{approvals} approvals, waiting for more reviews" if defined?(LOG)
342
+ gates_dispatched_at = task["gates_dispatched_at"]
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
+
348
+ # Re-dispatch gates if:
349
+ # 1. Gates were never dispatched (missed webhook), OR
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)
352
+ should_redispatch = if gates_dispatched_at.nil?
353
+ true
354
+ else
355
+ elapsed = Time.now - Time.parse(gates_dispatched_at)
356
+ elapsed > STALE_DISPATCH_TIMEOUT && responded_count < total_gates
357
+ end
358
+
359
+ if should_redispatch && ReviewGate.enabled?
360
+ github_repo = projects.dig(project_key, "github_repo")
361
+ if github_repo
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
383
+ Hooks.send(:save_epic_state, epic)
384
+ else
385
+ LOG.warn "[Basecamp] Resume: ##{card_number} — cannot re-dispatch gates, no github_repo for #{project_key}" if defined?(LOG)
386
+ end
387
+ else
388
+ LOG.info "[Basecamp] Resume: ##{card_number} has #{responded_count}/#{total_gates} gate responses, waiting for more reviews" if defined?(LOG)
389
+ end
287
390
  end
288
391
  end
289
392
 
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.6
4
+ version: 0.0.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis