brainiac-basecamp 0.0.13 → 0.0.15

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: 8adcfd4dedeee9ee8fee1e9439e80f7f3917d81ece1c5420a3777619df8d7ab5
4
- data.tar.gz: d88eb5a5e18d8bea39522ccfa6c8fbac13b2b50f87a28a29c6c1b8958e4f3eea
3
+ metadata.gz: e8e71f8f043a52cb54d83e974031b0bb83339f02452e701bf381e4e87b32274d
4
+ data.tar.gz: ac57b522a30cdb3cdf42c0aca069c52bb1ce27b2b93fdbdf4b63a2c9f57088b9
5
5
  SHA512:
6
- metadata.gz: 311711ad321fe576e24aeb51d82d9dffc4b9d2b6199b8f6b6242b7405583f224d1686d4b91cc395d3e16985dd73834324cbe539fe216f2eb22962a4f34d05d2d
7
- data.tar.gz: 7ccb5d585757a9b87b5dfa88512e44528048c153d4d5e159c4e62baa9c78f904dec5333899abdbaeec46b0a8da0ac6bfbd1cd39ca2530030f1d6e05e3a1d096c
6
+ metadata.gz: 955cfcffe81b1856b0ee90e8c2124b48d13b3de28db1e258054069442b8a67096deeb95cda1b5ce0e21b20cd2b78e3bd7c73fd107c5753c3de82ef0ead916d01
7
+ data.tar.gz: 7b6e92a54b136065013d178d3422bc7583958f83b161184c9905b2d950898907ee1eebdffa6d2cf07bc7394836b98aef52acd8bb50583231b2b6b8f2f9ca3247
@@ -16,7 +16,7 @@ module Brainiac
16
16
  # :resolve_base_branch — returns epic branch as worktree base
17
17
  # :resolve_pr_target — returns epic branch as PR target
18
18
  module Hooks
19
- class << self
19
+ class << self # rubocop:disable Metrics/ClassLength
20
20
  def register_all!
21
21
  register_agent_completed
22
22
  register_pr_merged
@@ -380,14 +380,39 @@ module Brainiac
380
380
  next unless epic["review_gate"] == "epic_branch" && ReviewGate.enabled?
381
381
 
382
382
  task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
383
- # Re-trigger gates if task is in_flight (agent working/pushed fixes)
384
- # No need to check for prior reviews — if task is in_flight with a PR, gates should review
385
- next unless task && task["status"] == "in_flight" && task["pr_number"]
383
+ next unless task && task["status"] == "in_flight"
384
+
385
+ # Ensure pr_number is set it may be missing if initial gate dispatch
386
+ # failed to persist it or if state was lost during serialization.
387
+ unless task["pr_number"]
388
+ pr_number = ctx.dig(:pull_request, "number") || ctx[:pr_number]
389
+ if pr_number
390
+ task["pr_number"] = pr_number.to_i
391
+ LOG.info "[Basecamp:Hooks] Backfilled pr_number=#{pr_number} for card ##{card_number}" if defined?(LOG)
392
+ else
393
+ # Try to find it from the branch
394
+ project_key = task["project"] || Config.brainiac_project_for(epic["basecamp_project_id"])
395
+ projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
396
+ projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
397
+ repo_path = projects.dig(project_key, "repo_path")
398
+ if repo_path
399
+ branch = ctx[:branch] || "fizzy-#{card_number}-*"
400
+ found_pr = find_pr_number(repo_path: repo_path, branch: branch)
401
+ if found_pr
402
+ task["pr_number"] = found_pr
403
+ LOG.info "[Basecamp:Hooks] Resolved pr_number=#{found_pr} for card ##{card_number} via branch lookup" if defined?(LOG)
404
+ end
405
+ end
406
+ end
407
+ end
408
+
409
+ next unless task["pr_number"]
386
410
 
387
411
  LOG.info "[Basecamp:Hooks] PR updated for card ##{card_number} — re-triggering review gates" if defined?(LOG)
388
412
 
389
413
  # Reset approvals and re-dispatch gates
390
414
  ReviewGate.reset_approvals(task)
415
+ task["changes_requested_by"] = []
391
416
  task["status"] = "in_review"
392
417
  epic["updated_at"] = Time.now.iso8601
393
418
  save_epic_state(epic)
@@ -821,6 +846,21 @@ module Brainiac
821
846
  return
822
847
  end
823
848
 
849
+ # Ensure pr_number is set — look it up if missing.
850
+ # This is critical for the pr_synchronized hook to re-trigger gates after fixes.
851
+ unless pr_number
852
+ found_pr = find_pr_number(repo_path: repo_path, branch: "fizzy-#{card_number}-*")
853
+ if found_pr
854
+ pr_number = found_pr
855
+ task["pr_number"] = pr_number
856
+ task["pr_repo"] = project_config["github_repo"]
857
+ save_epic_state(epic)
858
+ LOG.info "[Basecamp:Hooks] Backfilled pr_number=#{pr_number} for card ##{card_number} during direct dispatch" if defined?(LOG)
859
+ elsif defined?(LOG)
860
+ LOG.warn "[Basecamp:Hooks] Could not find PR for card ##{card_number} — gates won't re-trigger after fixes"
861
+ end
862
+ end
863
+
824
864
  # Find the worktree for this card
825
865
  work_items_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "work_items.json")
826
866
  worktree_path = repo_path # Default to main repo
@@ -189,6 +189,8 @@ module Brainiac
189
189
  load_epics.find { |e| e["id"] == epic_id }
190
190
  end
191
191
 
192
+ WEBHOOK_CHECK_COOLDOWN = 300 # 5 minutes
193
+
192
194
  private
193
195
 
194
196
  # Resolve current todolist state from Basecamp and dispatch unblocked cards.
@@ -264,7 +266,9 @@ module Brainiac
264
266
  end
265
267
 
266
268
  # Dispatch unblocked tasks that aren't already in-flight or complete.
269
+ # Ensures Fizzy webhooks are active before dispatching (prevents silent failures).
267
270
  def dispatch_unblocked_tasks(epic)
271
+ ensure_fizzy_webhooks_active
268
272
  tasks = epic["tasks"].map do |t|
269
273
  Epic::Task.new(
270
274
  todo_id: t["todo_id"],
@@ -316,7 +320,9 @@ module Brainiac
316
320
 
317
321
  log_event(epic, "dispatched", "Card ##{card_number} dispatched to #{agent}")
318
322
 
319
- # Assign the card in Fizzy via CLI — this triggers the normal Fizzy webhook flow
323
+ # Assign the card in Fizzy via CLI — this triggers the normal Fizzy webhook flow.
324
+ # If the agent is already assigned (stale state from a disabled webhook),
325
+ # the method will cycle the assignment to re-trigger the webhook.
320
326
  assign_fizzy_card(card_number, agent)
321
327
 
322
328
  # Post a comment on the Basecamp todo
@@ -416,7 +422,10 @@ module Brainiac
416
422
  end
417
423
 
418
424
  # Assign a Fizzy card to an agent via Fizzy CLI.
419
- # If the agent is already assigned, skip the webhook should have already fired.
425
+ # If the agent is already assigned, unassigns and reassigns to re-trigger the webhook
426
+ # (Fizzy webhooks can silently become disabled, causing the original assignment to be lost).
427
+ #
428
+ # @return [Symbol] :assigned, :reassigned, or :failed
420
429
  def assign_fizzy_card(card_number, agent)
421
430
  agent_config = load_agent_registry[agent.downcase]
422
431
  fizzy_name = agent_config&.dig("fizzy_name") || agent
@@ -425,18 +434,34 @@ module Brainiac
425
434
  fizzy_user_id = resolve_fizzy_user_id(fizzy_name)
426
435
  unless fizzy_user_id
427
436
  LOG.error "[Basecamp:Orchestrator] Could not resolve Fizzy user ID for '#{fizzy_name}'" if defined?(LOG)
428
- return
437
+ return :failed
429
438
  end
430
439
 
431
- # Check if agent is already assigned — if so, skip (webhook should have fired)
440
+ # Check if agent is already assigned
432
441
  stdout, _, status = Open3.capture3("fizzy", "card", "show", card_number.to_s, "--json")
433
442
  if status.success?
434
443
  card_data = JSON.parse(stdout).dig("data") rescue nil
435
444
  if card_data
436
445
  current_assignees = (card_data["assignees"] || []).map { |a| a["id"] }
437
446
  if current_assignees.include?(fizzy_user_id)
438
- LOG.info "[Basecamp:Orchestrator] Agent already assigned to ##{card_number}, skipping (webhook should have fired)" if defined?(LOG)
439
- return
447
+ # Agent is already assigned webhook may not have fired (disabled webhook).
448
+ # Unassign then reassign to trigger a fresh webhook delivery.
449
+ LOG.warn "[Basecamp:Orchestrator] Agent already assigned to ##{card_number} — " \
450
+ "cycling assignment to re-trigger webhook" if defined?(LOG)
451
+
452
+ # Fizzy assign TOGGLES, so calling it removes the assignment
453
+ Open3.capture3("fizzy", "card", "assign", card_number.to_s, "--user", fizzy_user_id)
454
+ sleep 1 # Brief pause to ensure the unassign is processed
455
+
456
+ # Now reassign — this should trigger the webhook
457
+ stdout2, stderr2, status2 = Open3.capture3("fizzy", "card", "assign", card_number.to_s, "--user", fizzy_user_id)
458
+ if status2.success?
459
+ LOG.info "[Basecamp:Orchestrator] Reassigned Fizzy ##{card_number} to #{fizzy_name} (webhook re-triggered)" if defined?(LOG)
460
+ return :reassigned
461
+ else
462
+ LOG.error "[Basecamp:Orchestrator] Failed to reassign Fizzy ##{card_number}: #{stderr2.strip}" if defined?(LOG)
463
+ return :failed
464
+ end
440
465
  end
441
466
  end
442
467
  end
@@ -445,11 +470,77 @@ module Brainiac
445
470
 
446
471
  if status.success?
447
472
  LOG.info "[Basecamp:Orchestrator] Assigned Fizzy ##{card_number} to #{fizzy_name} (#{fizzy_user_id})" if defined?(LOG)
473
+ :assigned
448
474
  else
449
475
  LOG.error "[Basecamp:Orchestrator] Failed to assign Fizzy ##{card_number}: #{stderr.strip}" if defined?(LOG)
476
+ :failed
450
477
  end
451
478
  rescue Errno::ENOENT => e
452
479
  LOG.error "[Basecamp:Orchestrator] fizzy CLI not found: #{e.message}" if defined?(LOG)
480
+ :failed
481
+ end
482
+
483
+ # Ensure Fizzy webhooks are active before dispatching cards.
484
+ # Fizzy webhooks can silently become disabled (e.g., after repeated delivery failures),
485
+ # causing assigned cards to never trigger agent work. This pre-flight check reactivates
486
+ # any disabled brainiac webhooks.
487
+ #
488
+ # Uses a cooldown to avoid hammering the API on every dispatch batch.
489
+ def ensure_fizzy_webhooks_active
490
+ now = Time.now
491
+ @last_webhook_check ||= Time.at(0)
492
+ return if (now - @last_webhook_check) < WEBHOOK_CHECK_COOLDOWN
493
+
494
+ @last_webhook_check = now
495
+
496
+ # Read fizzy config to get admin_token and board list
497
+ fizzy_config_file = File.join(BRAINIAC_DIR, "fizzy.json")
498
+ return unless File.exist?(fizzy_config_file)
499
+
500
+ fizzy_config = JSON.parse(File.read(fizzy_config_file))
501
+ admin_token = fizzy_config["admin_token"]
502
+ boards = fizzy_config["boards"] || {}
503
+
504
+ unless admin_token
505
+ LOG.debug "[Basecamp:Orchestrator] Fizzy webhook check skipped (no admin_token in fizzy.json)" if defined?(LOG)
506
+ return
507
+ end
508
+
509
+ env = { "FIZZY_TOKEN" => admin_token }
510
+ reactivated = 0
511
+
512
+ boards.each do |board_key, board_config|
513
+ board_id = board_config["board_id"]
514
+ next unless board_id
515
+
516
+ stdout, _, status = Open3.capture3(env, "fizzy", "webhook", "list",
517
+ "--board", board_id, "--all", "--json",
518
+ chdir: Dir.home)
519
+ next unless status.success?
520
+
521
+ webhooks = JSON.parse(stdout)["data"] || []
522
+ webhooks.each do |webhook|
523
+ next unless webhook["name"]&.start_with?("brainiac")
524
+ next if webhook["active"]
525
+
526
+ wh_name = webhook["name"]
527
+ LOG.warn "[Basecamp:Orchestrator] Fizzy webhook '#{wh_name}' on board '#{board_key}' is INACTIVE — reactivating" if defined?(LOG)
528
+ _, _, reactivate_status = Open3.capture3(env, "fizzy", "webhook", "reactivate", webhook["id"],
529
+ "--board", board_id, chdir: Dir.home)
530
+ if reactivate_status.success?
531
+ reactivated += 1
532
+ LOG.info "[Basecamp:Orchestrator] Reactivated webhook '#{webhook['name']}' on board '#{board_key}'" if defined?(LOG)
533
+ else
534
+ LOG.error "[Basecamp:Orchestrator] Failed to reactivate webhook '#{webhook['name']}' on board '#{board_key}'" if defined?(LOG)
535
+ end
536
+ end
537
+ rescue StandardError => e
538
+ LOG.warn "[Basecamp:Orchestrator] Webhook check failed for board '#{board_key}': #{e.message}" if defined?(LOG)
539
+ end
540
+
541
+ LOG.info "[Basecamp:Orchestrator] Fizzy webhook health check: #{reactivated} reactivated" if reactivated.positive?
542
+ rescue StandardError => e
543
+ LOG.warn "[Basecamp:Orchestrator] Fizzy webhook health check failed: #{e.message}" if defined?(LOG)
453
544
  end
454
545
 
455
546
  # Resolve a Fizzy user ID from their display name.
@@ -631,6 +722,15 @@ module Brainiac
631
722
  profile: epic["agent"]&.downcase)
632
723
  end
633
724
 
725
+ # Mark a Basecamp todo as incomplete (undo premature completion).
726
+ def uncheck_todo(epic, card_number)
727
+ task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
728
+ return unless task && task["todo_id"]
729
+
730
+ Client.run_safe("todos", "uncomplete", task["todo_id"].to_s, "--json",
731
+ profile: epic["agent"]&.downcase)
732
+ end
733
+
634
734
  # Post a completion comment on the Basecamp todo.
635
735
  def post_completion_comment(epic, card_number)
636
736
  task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
@@ -3,7 +3,7 @@
3
3
  module Brainiac
4
4
  module Plugins
5
5
  module Basecamp
6
- VERSION = "0.0.13"
6
+ VERSION = "0.0.15"
7
7
  end
8
8
  end
9
9
  end
@@ -160,7 +160,19 @@ module Brainiac
160
160
 
161
161
  when "in_review"
162
162
  # Check if all gates have actually approved (webhook might have missed)
163
- if pr_number && project_key && ReviewGate.enabled?
163
+ # Resolve pr_number if missing/zero (same pattern as final_decision)
164
+ effective_pr = pr_number
165
+ if (effective_pr.nil? || effective_pr.zero?) && project_key
166
+ resolved = resolve_pr_for_task(task)
167
+ if resolved
168
+ task["pr_number"] = resolved[:number]
169
+ task["pr_repo"] = resolved[:repo] if resolved[:repo]
170
+ effective_pr = resolved[:number]
171
+ LOG.info "[Basecamp:HealthCheck] Resolved PR ##{effective_pr} for in_review card ##{card_number}" if defined?(LOG)
172
+ end
173
+ end
174
+
175
+ if effective_pr&.positive? && project_key && ReviewGate.enabled?
164
176
  projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
165
177
  projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
166
178
  repo_path = projects.dig(project_key, "repo_path")
@@ -178,6 +190,28 @@ module Brainiac
178
190
  Hooks.send(:save_epic_state, epic)
179
191
  Hooks.send(:dispatch_final_decision, epic, task, {})
180
192
  healed_any = true
193
+ elsif task["changes_requested_by"]&.any? && pr_has_newer_commits_than_reviews?(task, repo_path: repo_path)
194
+ # Changes were requested but the impl agent has already pushed fixes.
195
+ # The pr_synchronized hook missed this (pr_number was 0, or status wasn't in_flight).
196
+ # Reset approvals and re-dispatch gates to review the new code.
197
+ LOG.info "[Basecamp:HealthCheck] PR has commits newer than last changes_requested review for ##{card_number} — re-dispatching gates" if defined?(LOG)
198
+ ReviewGate.reset_approvals(task)
199
+ task["changes_requested_by"] = []
200
+ task["gates_dispatched_at"] = Time.now.iso8601
201
+ Hooks.send(:save_epic_state, epic)
202
+
203
+ Thread.new do
204
+ ReviewGate.dispatch_gates(
205
+ epic: epic,
206
+ task: task,
207
+ pr_number: effective_pr,
208
+ repo_name: github_repo,
209
+ repo_path: repo_path
210
+ )
211
+ rescue StandardError => e
212
+ LOG.error "[Basecamp:HealthCheck] Gate re-dispatch failed for ##{card_number}: #{e.message}" if defined?(LOG)
213
+ end
214
+ healed_any = true
181
215
  elsif Hooks.send(:all_gates_responded?, task) && task["changes_requested_by"]&.any?
182
216
  # All gates responded but some requested changes — need to dispatch impl agent
183
217
  impl_agent = epic["agent"]
@@ -208,7 +242,7 @@ module Brainiac
208
242
  ReviewGate.dispatch_gates(
209
243
  epic: epic,
210
244
  task: task,
211
- pr_number: pr_number,
245
+ pr_number: effective_pr,
212
246
  repo_name: github_repo,
213
247
  repo_path: repo_path
214
248
  )
@@ -226,7 +260,7 @@ module Brainiac
226
260
  ReviewGate.dispatch_missing_gates(
227
261
  epic: epic,
228
262
  task: task,
229
- pr_number: pr_number,
263
+ pr_number: effective_pr,
230
264
  repo_name: github_repo,
231
265
  repo_path: repo_path
232
266
  )
@@ -237,6 +271,65 @@ module Brainiac
237
271
  end
238
272
  end
239
273
  end
274
+
275
+ when "complete"
276
+ # Sanity check: verify the PR is actually merged before accepting "complete" status.
277
+ # Catches race conditions where rapid review cycles cause premature task completion.
278
+ next unless project_key
279
+
280
+ effective_pr = pr_number
281
+ if effective_pr.nil? || effective_pr.zero?
282
+ resolved = resolve_pr_for_task(task)
283
+ if resolved
284
+ task["pr_number"] = resolved[:number]
285
+ task["pr_repo"] = resolved[:repo] if resolved[:repo]
286
+ effective_pr = resolved[:number]
287
+ end
288
+ end
289
+
290
+ # If we can't find a PR, we can't verify — skip (might be a no-code task)
291
+ next unless effective_pr&.positive?
292
+
293
+ projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
294
+ projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
295
+ github_repo = projects.dig(project_key, "github_repo")
296
+ repo_path = projects.dig(project_key, "repo_path")
297
+ next unless github_repo
298
+
299
+ pr_state, = Open3.capture2("gh", "pr", "view", effective_pr.to_s, "--repo", github_repo, "--json", "state", "-q", ".state")
300
+ pr_state = pr_state.strip
301
+
302
+ if pr_state == "OPEN"
303
+ # PR is NOT merged — task was prematurely marked complete.
304
+ # Revert to in_review and re-dispatch gates to review current state.
305
+ LOG.info "[Basecamp:HealthCheck] Task ##{card_number} marked complete but PR ##{effective_pr} is still OPEN — reverting to in_review" if defined?(LOG)
306
+
307
+ task["status"] = "in_review"
308
+ task.delete("completed_at")
309
+ task["gate_approvals"] = []
310
+ task["changes_requested_by"] = []
311
+ task["gates_dispatched_at"] = Time.now.iso8601
312
+ Hooks.send(:save_epic_state, epic)
313
+
314
+ # Uncheck the Basecamp todo (it was incorrectly checked)
315
+ Orchestrator.send(:uncheck_todo, epic, card_number) if Orchestrator.respond_to?(:uncheck_todo, true)
316
+
317
+ # Re-dispatch review gates
318
+ if ReviewGate.enabled? && repo_path
319
+ Thread.new do
320
+ ReviewGate.dispatch_gates(
321
+ epic: epic,
322
+ task: task,
323
+ pr_number: effective_pr,
324
+ repo_name: github_repo,
325
+ repo_path: repo_path
326
+ )
327
+ rescue StandardError => e
328
+ LOG.error "[Basecamp:HealthCheck] Gate dispatch failed for ##{card_number}: #{e.message}" if defined?(LOG)
329
+ end
330
+ end
331
+ healed_any = true
332
+ end
240
333
  end
241
334
  end
242
335
 
@@ -249,6 +342,36 @@ module Brainiac
249
342
 
250
343
  private
251
344
 
345
+ # Check if a PR has commits newer than the most recent changes_requested review.
346
+ # This detects when the impl agent pushed fixes but gates weren't re-triggered.
347
+ #
348
+ # @param task [Hash] Task state with pr_number
349
+ # @param repo_path [String] Local repo path for gh CLI
350
+ # @return [Boolean] true if PR has commits after the last changes_requested review
351
+ def pr_has_newer_commits_than_reviews?(task, repo_path:)
352
+ pr_number = task["pr_number"]
353
+ return false unless pr_number&.positive?
354
+
355
+ # Get the last changes_requested review timestamp and the latest commit timestamp
356
+ stdout, _, status = Open3.capture3(
357
+ "gh", "pr", "view", pr_number.to_s,
358
+ "--json", "reviews,commits",
359
+ "--jq", "[(.reviews | map(select(.state == \"CHANGES_REQUESTED\")) | sort_by(.submittedAt) | last | .submittedAt), (.commits | last | .committedDate)] | @tsv",
360
+ chdir: repo_path
361
+ )
362
+ return false unless status.success? && !stdout.strip.empty?
363
+
364
+ last_changes_at, last_commit_at = stdout.strip.split("\t")
365
+ return false if last_changes_at.nil? || last_changes_at.empty? || last_commit_at.nil? || last_commit_at.empty?
366
+
367
+ # Compare timestamps — if the latest commit is newer than the last changes_requested review,
368
+ # the impl agent has pushed fixes that haven't been reviewed yet.
369
+ Time.parse(last_commit_at) > Time.parse(last_changes_at)
370
+ rescue StandardError => e
371
+ LOG.warn "[Basecamp:HealthCheck] Error checking PR commits vs reviews: #{e.message}" if defined?(LOG)
372
+ false
373
+ end
374
+
252
375
  # Resume active epics on server startup.
253
376
  # For each epic, checks task states and takes appropriate action.
254
377
  def resume_active_epics(epics)
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.13
4
+ version: 0.0.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis