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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f3b4d7d9e99a700f2a533b48110071025bde9eb69b55633acffd4125f88c5540
|
|
4
|
+
data.tar.gz: 63635c64597b65ed6e0a69e90511010d6975c418249c7a181d9a0349bded7dcc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
@@ -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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
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
|
|
328
|
+
LOG.info "[Basecamp] Resume: ##{card_number} has changes requested — re-dispatching #{impl_agent}" if defined?(LOG)
|
|
266
329
|
|
|
267
|
-
#
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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 —
|
|
340
|
+
# No changes requested yet — check if gates need (re-)dispatching
|
|
285
341
|
approvals = task["gate_approvals"]&.size || 0
|
|
286
|
-
|
|
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
|
|