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: 513a24a33fad368f0f563ac5d06c76dccaacbd0aff2eb681fedd8860d9866db3
4
- data.tar.gz: 39eb703375c5757c66443de641534c8b495147b07be6ce3339ec0a2a311bbba8
3
+ metadata.gz: 630e96430ff517dde6be1c3766ad6f984bb86a4e0b876d55b5d2f205050fbc19
4
+ data.tar.gz: b6711ef8f8fd3aec32b3f09aea604dee8c7d138f25f4cd03b94096a9787f218a
5
5
  SHA512:
6
- metadata.gz: 1a0b1c33b666d782f40f8203676369d30fa5b38d0e0b4ce43b880197dfe0caa968cd8bb9197a1027a7ff551003af56f22552e84be966c303ee2fab335560afbe
7
- data.tar.gz: e34a0b748449f3736c32c7e4ea35a51edf3940bb6334b0577e7dd48341da11b5988bb1dbaa59645c77ffb2399c0d0f9e24a822aeb61d760cd01ebb511ea4e0f3
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
- # brainiac-github will dispatch the impl agent since this is changes_requested
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
- Open3.capture3("fizzy", "card", "assign", card_number.to_s, "--user", fizzy_user_id) if fizzy_user_id
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
- # Only re-trigger if the task is in_flight (fixes pushed) and had prior reviews
330
- # Check for ANY prior reviews (approvals OR changes_requested)
331
- had_prior_reviews = task["gate_approvals"]&.any? || task["changes_requested_by"]&.any?
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
@@ -3,7 +3,7 @@
3
3
  module Brainiac
4
4
  module Plugins
5
5
  module Basecamp
6
- VERSION = "0.0.3"
6
+ VERSION = "0.0.4"
7
7
  end
8
8
  end
9
9
  end
@@ -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
- # Gates haven't all approvedcheck if we need to dispatch fixes or wait
284
+ # No changes requested yetjust waiting for reviews
137
285
  approvals = task["gate_approvals"]&.size || 0
138
- changes = task["changes_requested_by"]&.size || 0
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
 
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.3
4
+ version: 0.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis