brainiac-basecamp 0.0.3 → 0.0.4
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: 630e96430ff517dde6be1c3766ad6f984bb86a4e0b876d55b5d2f205050fbc19
|
|
4
|
+
data.tar.gz: b6711ef8f8fd3aec32b3f09aea604dee8c7d138f25f4cd03b94096a9787f218a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cb20e71bf28a7a66cd777245b1751ca363c13ac9062e113045c6316b921bc1ed794aa3f856a62d72f988699e452d4f5ef0ec1fb518d6c5b0324f3559ff4e387a
|
|
7
|
+
data.tar.gz: f2ef017df5c1b146c31e16fcb030bdb660c13e97d336cb96735d848a278cfc657f9e0cad2d96d12269a79884426835157534f0ad6243fb111d6fa39dc5c68fc8
|
|
@@ -214,8 +214,16 @@ module Brainiac
|
|
|
214
214
|
# All gates have reviewed — dispatch implementation agent to address ALL feedback
|
|
215
215
|
LOG.info "[Basecamp:Hooks] All gates responded for card ##{card_number} — dispatching fixes" if defined?(LOG)
|
|
216
216
|
task["status"] = "in_flight"
|
|
217
|
+
task.delete("changes_debounce_started")
|
|
217
218
|
save_epic_state(epic)
|
|
218
|
-
|
|
219
|
+
|
|
220
|
+
# Re-assign card to implementation agent to trigger dispatch
|
|
221
|
+
impl_agent = epic["agent"]
|
|
222
|
+
fizzy_user_id = Orchestrator.send(:resolve_fizzy_user_id, impl_agent)
|
|
223
|
+
if fizzy_user_id
|
|
224
|
+
LOG.info "[Basecamp:Hooks] Re-assigning card ##{card_number} to #{impl_agent} for fixes" if defined?(LOG)
|
|
225
|
+
safe_assign_card(card_number, fizzy_user_id)
|
|
226
|
+
end
|
|
219
227
|
else
|
|
220
228
|
# Wait for remaining gates to respond
|
|
221
229
|
responded = (task["gate_approvals"]&.size || 0) + (task["changes_requested_by"]&.size || 0)
|
|
@@ -242,7 +250,7 @@ module Brainiac
|
|
|
242
250
|
save_epic_state(epic_reloaded)
|
|
243
251
|
# Re-assign card to trigger dispatch
|
|
244
252
|
fizzy_user_id = Orchestrator.send(:resolve_fizzy_user_id, epic_reloaded["agent"])
|
|
245
|
-
|
|
253
|
+
safe_assign_card(card_number, fizzy_user_id) if fizzy_user_id
|
|
246
254
|
end
|
|
247
255
|
rescue StandardError => e
|
|
248
256
|
LOG.error "[Basecamp:Hooks] Debounce dispatch failed: #{e.message}" if defined?(LOG)
|
|
@@ -314,8 +322,46 @@ module Brainiac
|
|
|
314
322
|
false
|
|
315
323
|
end
|
|
316
324
|
|
|
325
|
+
# Safely assign a card to a user, checking if they're already assigned first.
|
|
326
|
+
# Fizzy's assign command TOGGLES assignment, so calling it when already assigned
|
|
327
|
+
# will unassign the user. This method prevents that.
|
|
328
|
+
#
|
|
329
|
+
# @param card_number [Integer, String] Fizzy card number
|
|
330
|
+
# @param fizzy_user_id [String] Fizzy user ID to assign
|
|
331
|
+
# @return [Boolean] true if assigned, false if already assigned or failed
|
|
332
|
+
def safe_assign_card(card_number, fizzy_user_id)
|
|
333
|
+
return false unless fizzy_user_id
|
|
334
|
+
|
|
335
|
+
# Check current assignees
|
|
336
|
+
stdout, _, status = Open3.capture3("fizzy", "card", "show", card_number.to_s, "--json")
|
|
337
|
+
if status.success?
|
|
338
|
+
card_data = JSON.parse(stdout).dig("data") rescue nil
|
|
339
|
+
if card_data
|
|
340
|
+
current_assignees = (card_data["assignees"] || []).map { |a| a["id"] }
|
|
341
|
+
if current_assignees.include?(fizzy_user_id)
|
|
342
|
+
LOG.info "[Basecamp:Hooks] User #{fizzy_user_id} already assigned to ##{card_number}, skipping" if defined?(LOG)
|
|
343
|
+
return false
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Not assigned — safe to assign
|
|
349
|
+
_, stderr, status = Open3.capture3("fizzy", "card", "assign", card_number.to_s, "--user", fizzy_user_id)
|
|
350
|
+
if status.success?
|
|
351
|
+
LOG.info "[Basecamp:Hooks] Assigned ##{card_number} to #{fizzy_user_id}" if defined?(LOG)
|
|
352
|
+
true
|
|
353
|
+
else
|
|
354
|
+
LOG.error "[Basecamp:Hooks] Failed to assign ##{card_number}: #{stderr.strip}" if defined?(LOG)
|
|
355
|
+
false
|
|
356
|
+
end
|
|
357
|
+
rescue StandardError => e
|
|
358
|
+
LOG.error "[Basecamp:Hooks] Error in safe_assign_card: #{e.message}" if defined?(LOG)
|
|
359
|
+
false
|
|
360
|
+
end
|
|
361
|
+
|
|
317
362
|
# When a PR is updated (new commits pushed) — re-trigger gates if in review.
|
|
318
363
|
# This handles the "Galen fixes → pushes → gates re-review" flow.
|
|
364
|
+
# Also catches edge cases where gates weren't dispatched initially.
|
|
319
365
|
def register_pr_synchronized
|
|
320
366
|
Brainiac.on(:pr_synchronized) do |ctx|
|
|
321
367
|
card_number = ctx[:card_number]
|
|
@@ -326,10 +372,9 @@ module Brainiac
|
|
|
326
372
|
next unless epic["review_gate"] == "epic_branch" && ReviewGate.enabled?
|
|
327
373
|
|
|
328
374
|
task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
|
|
329
|
-
#
|
|
330
|
-
#
|
|
331
|
-
|
|
332
|
-
next unless task && task["status"] == "in_flight" && had_prior_reviews
|
|
375
|
+
# Re-trigger gates if task is in_flight (agent working/pushed fixes)
|
|
376
|
+
# No need to check for prior reviews — if task is in_flight with a PR, gates should review
|
|
377
|
+
next unless task && task["status"] == "in_flight" && task["pr_number"]
|
|
333
378
|
|
|
334
379
|
LOG.info "[Basecamp:Hooks] PR updated for card ##{card_number} — re-triggering review gates" if defined?(LOG)
|
|
335
380
|
|
|
@@ -565,6 +610,22 @@ module Brainiac
|
|
|
565
610
|
|
|
566
611
|
LOG.info "[Basecamp:Hooks] Dispatching #{agent_name} for final decision on card ##{card_number}" if defined?(LOG)
|
|
567
612
|
|
|
613
|
+
# Get project config first — we need it for PR check and agent dispatch
|
|
614
|
+
projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
|
|
615
|
+
projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
|
|
616
|
+
project_config = projects[project_key]
|
|
617
|
+
github_repo = project_config&.dig("github_repo")
|
|
618
|
+
|
|
619
|
+
# Check if PR is already merged (handles manual merges, webhook misses, etc.)
|
|
620
|
+
if github_repo && pr_number
|
|
621
|
+
pr_state, = Open3.capture2("gh", "pr", "view", pr_number.to_s, "--repo", github_repo, "--json", "state", "-q", ".state")
|
|
622
|
+
if pr_state.strip == "MERGED"
|
|
623
|
+
LOG.info "[Basecamp:Hooks] PR ##{pr_number} already merged — marking card ##{card_number} complete" if defined?(LOG)
|
|
624
|
+
Orchestrator.on_card_completed(card_number)
|
|
625
|
+
return
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
|
|
568
629
|
# Mark task as awaiting final decision
|
|
569
630
|
task["awaiting_final_decision"] = true
|
|
570
631
|
task["status"] = "final_decision"
|
|
@@ -582,10 +643,6 @@ module Brainiac
|
|
|
582
643
|
end
|
|
583
644
|
end
|
|
584
645
|
|
|
585
|
-
# Get project config and repo path
|
|
586
|
-
projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
|
|
587
|
-
projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
|
|
588
|
-
project_config = projects[project_key]
|
|
589
646
|
repo_path = project_config&.dig("repo_path")
|
|
590
647
|
|
|
591
648
|
unless repo_path
|
|
@@ -623,6 +623,12 @@ module Brainiac
|
|
|
623
623
|
|
|
624
624
|
# Complete the entire epic.
|
|
625
625
|
def complete_epic(epic)
|
|
626
|
+
# Idempotency — don't complete twice
|
|
627
|
+
if epic["status"] == "complete"
|
|
628
|
+
LOG.info "[Basecamp:Orchestrator] Epic '#{epic['title']}' already complete, skipping duplicate completion" if defined?(LOG)
|
|
629
|
+
return
|
|
630
|
+
end
|
|
631
|
+
|
|
626
632
|
epic["status"] = "complete"
|
|
627
633
|
epic["completed_at"] = Time.now.iso8601
|
|
628
634
|
epic["updated_at"] = Time.now.iso8601
|
|
@@ -47,9 +47,136 @@ module Brainiac
|
|
|
47
47
|
end
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# Start background health check thread for active epics
|
|
51
|
+
start_epic_health_monitor
|
|
52
|
+
|
|
50
53
|
LOG.info "[Basecamp] Plugin registered (webhook: /basecamp, review_gate: #{Config.review_gate})"
|
|
51
54
|
end
|
|
52
55
|
|
|
56
|
+
# Background thread that periodically checks for stuck epic states and auto-heals.
|
|
57
|
+
# Runs every 90 seconds to catch:
|
|
58
|
+
# - final_decision tasks where PR is already merged
|
|
59
|
+
# - in_flight tasks where impl agent isn't assigned
|
|
60
|
+
# - in_review tasks where all gates have actually approved (webhook missed)
|
|
61
|
+
def start_epic_health_monitor
|
|
62
|
+
@health_monitor_thread = Thread.new do
|
|
63
|
+
loop do
|
|
64
|
+
sleep 90 # Check every 90 seconds
|
|
65
|
+
|
|
66
|
+
begin
|
|
67
|
+
active_epics = Orchestrator.active_epics
|
|
68
|
+
next if active_epics.empty?
|
|
69
|
+
|
|
70
|
+
active_epics.each do |epic|
|
|
71
|
+
health_check_epic(epic)
|
|
72
|
+
end
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
LOG.error "[Basecamp:HealthCheck] Error: #{e.message}" if defined?(LOG)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
@health_monitor_thread.abort_on_exception = false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Check an epic for stuck states and auto-heal
|
|
82
|
+
def health_check_epic(epic)
|
|
83
|
+
healed_any = false
|
|
84
|
+
|
|
85
|
+
epic["tasks"]&.each do |task|
|
|
86
|
+
card_number = task["fizzy_card"]
|
|
87
|
+
status = task["status"]
|
|
88
|
+
pr_number = task["pr_number"]
|
|
89
|
+
project_key = task["project"]
|
|
90
|
+
|
|
91
|
+
case status
|
|
92
|
+
when "final_decision"
|
|
93
|
+
# Check if PR is already merged
|
|
94
|
+
if pr_number && project_key
|
|
95
|
+
projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
|
|
96
|
+
projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
|
|
97
|
+
github_repo = projects.dig(project_key, "github_repo")
|
|
98
|
+
|
|
99
|
+
if github_repo
|
|
100
|
+
pr_state, = Open3.capture2("gh", "pr", "view", pr_number.to_s, "--repo", github_repo, "--json", "state", "-q", ".state")
|
|
101
|
+
if pr_state.strip == "MERGED"
|
|
102
|
+
LOG.info "[Basecamp:HealthCheck] PR ##{pr_number} merged but task ##{card_number} stuck in final_decision — healing" if defined?(LOG)
|
|
103
|
+
Orchestrator.on_card_completed(card_number)
|
|
104
|
+
healed_any = true
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
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)
|
|
120
|
+
fizzy_user_id = Orchestrator.send(:resolve_fizzy_user_id, impl_agent)
|
|
121
|
+
if fizzy_user_id
|
|
122
|
+
Open3.capture3("fizzy", "card", "assign", card_number.to_s, "--user", fizzy_user_id)
|
|
123
|
+
healed_any = true
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
when "in_review"
|
|
129
|
+
# Check if all gates have actually approved (webhook might have missed)
|
|
130
|
+
if pr_number && project_key && ReviewGate.enabled?
|
|
131
|
+
projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
|
|
132
|
+
projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
|
|
133
|
+
repo_path = projects.dig(project_key, "repo_path")
|
|
134
|
+
|
|
135
|
+
if repo_path
|
|
136
|
+
# Sync from GitHub to catch any missed approvals
|
|
137
|
+
sync_result = ReviewGate.sync_from_github(task, repo_path: repo_path)
|
|
138
|
+
|
|
139
|
+
if ReviewGate.all_gates_passed?(task)
|
|
140
|
+
# All approved — advance to final_decision
|
|
141
|
+
LOG.info "[Basecamp:HealthCheck] All gates passed for ##{card_number} but still in_review — healing" if defined?(LOG)
|
|
142
|
+
task["status"] = "final_decision"
|
|
143
|
+
task["awaiting_final_decision"] = true
|
|
144
|
+
Hooks.send(:save_epic_state, epic)
|
|
145
|
+
Hooks.send(:dispatch_final_decision, epic, task, {})
|
|
146
|
+
healed_any = true
|
|
147
|
+
elsif Hooks.send(:all_gates_responded?, task) && task["changes_requested_by"]&.any?
|
|
148
|
+
# All gates responded but some requested changes — need to dispatch impl agent
|
|
149
|
+
impl_agent = epic["agent"]
|
|
150
|
+
card_json, = Open3.capture2("fizzy", "card", "show", card_number.to_s, "--json")
|
|
151
|
+
card_data = JSON.parse(card_json) rescue {}
|
|
152
|
+
assignees = card_data.dig("data", "assignees") || []
|
|
153
|
+
assignee_names = assignees.map { |a| a["name"]&.downcase }
|
|
154
|
+
|
|
155
|
+
LOG.info "[Basecamp:HealthCheck] All gates responded for ##{card_number} with changes_requested — transitioning to in_flight" if defined?(LOG)
|
|
156
|
+
task["status"] = "in_flight"
|
|
157
|
+
Hooks.send(:save_epic_state, epic)
|
|
158
|
+
|
|
159
|
+
unless assignee_names.include?(impl_agent.downcase)
|
|
160
|
+
LOG.info "[Basecamp:HealthCheck] Re-assigning ##{card_number} to #{impl_agent}" if defined?(LOG)
|
|
161
|
+
fizzy_user_id = Orchestrator.send(:resolve_fizzy_user_id, impl_agent)
|
|
162
|
+
if fizzy_user_id
|
|
163
|
+
Open3.capture3("fizzy", "card", "assign", card_number.to_s, "--user", fizzy_user_id)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
healed_any = true
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# If we healed anything, check if we can dispatch more tasks
|
|
174
|
+
if healed_any
|
|
175
|
+
Orchestrator.send(:dispatch_unblocked_tasks, epic)
|
|
176
|
+
Orchestrator.send(:save_epic, epic)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
53
180
|
private
|
|
54
181
|
|
|
55
182
|
# Resume active epics on server startup.
|
|
@@ -132,11 +259,31 @@ module Brainiac
|
|
|
132
259
|
task["awaiting_final_decision"] = true
|
|
133
260
|
Hooks.send(:save_epic_state, epic)
|
|
134
261
|
Hooks.send(:dispatch_final_decision, epic, task, {})
|
|
262
|
+
elsif task["changes_requested_by"]&.any?
|
|
263
|
+
# Gates requested changes — check if impl agent is assigned
|
|
264
|
+
impl_agent = epic["agent"]
|
|
265
|
+
LOG.info "[Basecamp] Resume: ##{card_number} has changes requested, checking assignment" if defined?(LOG)
|
|
266
|
+
|
|
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 }
|
|
272
|
+
|
|
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
|
|
282
|
+
end
|
|
135
283
|
else
|
|
136
|
-
#
|
|
284
|
+
# No changes requested yet — just waiting for reviews
|
|
137
285
|
approvals = task["gate_approvals"]&.size || 0
|
|
138
|
-
|
|
139
|
-
LOG.info "[Basecamp] Resume: ##{card_number} has #{approvals} approvals, #{changes} changes_requested — waiting" if defined?(LOG)
|
|
286
|
+
LOG.info "[Basecamp] Resume: ##{card_number} has #{approvals} approvals, waiting for more reviews" if defined?(LOG)
|
|
140
287
|
end
|
|
141
288
|
end
|
|
142
289
|
|