brainiac-basecamp 0.0.16 → 0.0.18
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 +4 -4
- data/lib/brainiac/plugins/basecamp/cli.rb +534 -10
- data/lib/brainiac/plugins/basecamp/comment_responder.rb +380 -0
- data/lib/brainiac/plugins/basecamp/epic_memory.rb +10 -0
- data/lib/brainiac/plugins/basecamp/hooks.rb +94 -270
- data/lib/brainiac/plugins/basecamp/orchestrator.rb +65 -187
- data/lib/brainiac/plugins/basecamp/review_gate.rb +165 -218
- data/lib/brainiac/plugins/basecamp/session_registry.rb +361 -0
- data/lib/brainiac/plugins/basecamp/task_state.rb +86 -0
- data/lib/brainiac/plugins/basecamp/version.rb +1 -1
- data/lib/brainiac/plugins/basecamp/webhook.rb +4 -6
- data/lib/brainiac/plugins/basecamp.rb +128 -492
- metadata +4 -1
|
@@ -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
|
|
20
20
|
def register_all!
|
|
21
21
|
register_agent_completed
|
|
22
22
|
register_pr_merged
|
|
@@ -36,7 +36,6 @@ module Brainiac
|
|
|
36
36
|
def register_agent_completed
|
|
37
37
|
Brainiac.on(:agent_completed) do |ctx|
|
|
38
38
|
next unless ctx[:source] == :fizzy
|
|
39
|
-
next unless ctx[:exit_status]&.zero? && !ctx[:signaled]
|
|
40
39
|
|
|
41
40
|
card_number = ctx[:card_number]
|
|
42
41
|
next unless card_number
|
|
@@ -44,6 +43,15 @@ module Brainiac
|
|
|
44
43
|
epic = Orchestrator.find_epic_for_card(card_number)
|
|
45
44
|
next unless epic
|
|
46
45
|
|
|
46
|
+
implementation_session = SessionRegistry.find_session(
|
|
47
|
+
SessionRegistry.implementation_session_id(card_number)
|
|
48
|
+
)
|
|
49
|
+
if implementation_session && implementation_session["agent_name"].to_s.casecmp?(ctx[:agent_name].to_s)
|
|
50
|
+
SessionRegistry.mark_dead(implementation_session["task_id"])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
next unless ctx[:exit_status]&.zero? && !ctx[:signaled]
|
|
54
|
+
|
|
47
55
|
review_gate = epic["review_gate"] || Config.review_gate
|
|
48
56
|
|
|
49
57
|
case review_gate
|
|
@@ -64,7 +72,7 @@ module Brainiac
|
|
|
64
72
|
|
|
65
73
|
if ReviewGate.enabled?
|
|
66
74
|
# Dispatch review gates in parallel after a delay (wait for PR to be created)
|
|
67
|
-
task
|
|
75
|
+
TaskState.transition!(task, :submit_for_review, triggered_by: "agent_completed")
|
|
68
76
|
epic["updated_at"] = Time.now.iso8601
|
|
69
77
|
save_epic_state(epic)
|
|
70
78
|
|
|
@@ -123,18 +131,6 @@ module Brainiac
|
|
|
123
131
|
if epic_branches.include?(base_branch)
|
|
124
132
|
LOG.info "[Basecamp:Hooks] PR merged to epic branch #{base_branch} for card ##{card_number} — advancing" if defined?(LOG)
|
|
125
133
|
Orchestrator.on_card_completed(card_number)
|
|
126
|
-
elsif epic_branches.empty?
|
|
127
|
-
# Fallback: epic_branches was never populated (branch creation failed).
|
|
128
|
-
# If the task is in final_decision or in_review, the merge still represents
|
|
129
|
-
# completion — advance the epic regardless of which branch was targeted.
|
|
130
|
-
task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
|
|
131
|
-
if task && %w[final_decision in_review in_flight].include?(task["status"])
|
|
132
|
-
if defined?(LOG)
|
|
133
|
-
LOG.warn "[Basecamp:Hooks] epic_branches is empty but PR merged to '#{base_branch}' " \
|
|
134
|
-
"for card ##{card_number} (status: #{task['status']}) — advancing anyway (branch creation likely failed)"
|
|
135
|
-
end
|
|
136
|
-
Orchestrator.on_card_completed(card_number)
|
|
137
|
-
end
|
|
138
134
|
end
|
|
139
135
|
end
|
|
140
136
|
end
|
|
@@ -193,8 +189,6 @@ module Brainiac
|
|
|
193
189
|
if review_state == "approved"
|
|
194
190
|
LOG.info "[Basecamp:Hooks] Gate APPROVED: #{agent_name} (#{role}) on card ##{card_number}" if defined?(LOG)
|
|
195
191
|
ReviewGate.record_approval(task, agent: agent_name, role: role)
|
|
196
|
-
# Clear this gate from changes_requested if it was there
|
|
197
|
-
task["changes_requested_by"]&.delete(agent_name)
|
|
198
192
|
epic["updated_at"] = Time.now.iso8601
|
|
199
193
|
|
|
200
194
|
if ReviewGate.all_gates_passed?(task)
|
|
@@ -207,17 +201,15 @@ module Brainiac
|
|
|
207
201
|
LOG.error "[Basecamp:Hooks] Final decision dispatch failed: #{e.message}" if defined?(LOG)
|
|
208
202
|
end
|
|
209
203
|
else
|
|
210
|
-
|
|
211
|
-
remaining = ReviewGate.gates.size -
|
|
212
|
-
LOG.info "[Basecamp:Hooks] #{
|
|
204
|
+
approved = ReviewGate.gate_states(task).values.count { |state| state["status"] == "approved" }
|
|
205
|
+
remaining = ReviewGate.gates.size - approved
|
|
206
|
+
LOG.info "[Basecamp:Hooks] #{approved}/#{ReviewGate.gates.size} gates passed, #{remaining} remaining" if defined?(LOG)
|
|
213
207
|
save_epic_state(epic)
|
|
214
208
|
end
|
|
215
209
|
elsif review_state == "changes_requested"
|
|
216
210
|
LOG.info "[Basecamp:Hooks] Gate CHANGES_REQUESTED: #{agent_name} (#{role}) on card ##{card_number}" if defined?(LOG)
|
|
217
211
|
|
|
218
|
-
|
|
219
|
-
task["changes_requested_by"] ||= []
|
|
220
|
-
task["changes_requested_by"] << agent_name unless task["changes_requested_by"].include?(agent_name)
|
|
212
|
+
ReviewGate.record_changes_requested(task, agent: agent_name, role: role)
|
|
221
213
|
|
|
222
214
|
# Check if all gates have now responded (either approved or requested changes)
|
|
223
215
|
all_responded = all_gates_responded?(task)
|
|
@@ -225,16 +217,15 @@ module Brainiac
|
|
|
225
217
|
if all_responded
|
|
226
218
|
# All gates have reviewed — dispatch implementation agent to address ALL feedback
|
|
227
219
|
LOG.info "[Basecamp:Hooks] All gates responded for card ##{card_number} — dispatching fixes" if defined?(LOG)
|
|
228
|
-
task
|
|
229
|
-
task["dispatched_at"] = Time.now.iso8601
|
|
220
|
+
TaskState.transition!(task, :request_changes, triggered_by: "review_changes_requested")
|
|
230
221
|
task.delete("changes_debounce_started")
|
|
231
222
|
save_epic_state(epic)
|
|
232
223
|
|
|
233
|
-
|
|
224
|
+
LOG.info "[Basecamp:Hooks] Direct-dispatching implementation for fixes on ##{card_number}" if defined?(LOG)
|
|
234
225
|
dispatch_impl_directly(epic, task)
|
|
235
226
|
else
|
|
236
227
|
# Wait for remaining gates to respond
|
|
237
|
-
responded = (task
|
|
228
|
+
responded = ReviewGate.responded_count(task)
|
|
238
229
|
remaining = ReviewGate.gates.size - responded
|
|
239
230
|
LOG.info "[Basecamp:Hooks] #{responded}/#{ReviewGate.gates.size} gates responded, waiting for #{remaining} more" if defined?(LOG)
|
|
240
231
|
save_epic_state(epic)
|
|
@@ -251,13 +242,12 @@ module Brainiac
|
|
|
251
242
|
next unless epic_reloaded
|
|
252
243
|
|
|
253
244
|
task_reloaded = epic_reloaded["tasks"]&.find { |t| t["fizzy_card"] == card_number.to_i }
|
|
254
|
-
if task_reloaded && task_reloaded["status"] == "in_review" && task_reloaded
|
|
245
|
+
if task_reloaded && task_reloaded["status"] == "in_review" && ReviewGate.changes_requested?(task_reloaded)
|
|
255
246
|
LOG.info "[Basecamp:Hooks] Debounce timeout for card ##{card_number} — forcing dispatch" if defined?(LOG)
|
|
256
|
-
task_reloaded
|
|
257
|
-
task_reloaded["dispatched_at"] = Time.now.iso8601
|
|
247
|
+
TaskState.transition!(task_reloaded, :request_changes, triggered_by: "review_changes_timeout")
|
|
258
248
|
task_reloaded.delete("changes_debounce_started")
|
|
259
249
|
save_epic_state(epic_reloaded)
|
|
260
|
-
#
|
|
250
|
+
# Re-assign card to trigger dispatch
|
|
261
251
|
dispatch_impl_directly(epic_reloaded, task_reloaded)
|
|
262
252
|
end
|
|
263
253
|
rescue StandardError => e
|
|
@@ -271,12 +261,7 @@ module Brainiac
|
|
|
271
261
|
|
|
272
262
|
# Check if all configured gates have responded (either approved or requested changes)
|
|
273
263
|
def all_gates_responded?(task)
|
|
274
|
-
|
|
275
|
-
changes = (task["changes_requested_by"] || []).map(&:downcase)
|
|
276
|
-
responded = approvals + changes
|
|
277
|
-
|
|
278
|
-
required = ReviewGate.gates.map { |g| g["agent"].downcase }
|
|
279
|
-
required.all? { |agent| responded.include?(agent) }
|
|
264
|
+
ReviewGate.all_gates_responded?(task)
|
|
280
265
|
end
|
|
281
266
|
|
|
282
267
|
# Match a GitHub reviewer login to a configured gate agent.
|
|
@@ -380,40 +365,15 @@ module Brainiac
|
|
|
380
365
|
next unless epic["review_gate"] == "epic_branch" && ReviewGate.enabled?
|
|
381
366
|
|
|
382
367
|
task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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"]
|
|
368
|
+
# Re-trigger gates if task is in_flight (agent working/pushed fixes)
|
|
369
|
+
# No need to check for prior reviews — if task is in_flight with a PR, gates should review
|
|
370
|
+
next unless task && task["status"] == "in_flight" && task["pr_number"]
|
|
410
371
|
|
|
411
372
|
LOG.info "[Basecamp:Hooks] PR updated for card ##{card_number} — re-triggering review gates" if defined?(LOG)
|
|
412
373
|
|
|
413
374
|
# Reset approvals and re-dispatch gates
|
|
414
375
|
ReviewGate.reset_approvals(task)
|
|
415
|
-
task
|
|
416
|
-
task["status"] = "in_review"
|
|
376
|
+
TaskState.transition!(task, :submit_for_review, triggered_by: "pr_synchronized")
|
|
417
377
|
epic["updated_at"] = Time.now.iso8601
|
|
418
378
|
save_epic_state(epic)
|
|
419
379
|
|
|
@@ -446,23 +406,6 @@ module Brainiac
|
|
|
446
406
|
""
|
|
447
407
|
]
|
|
448
408
|
|
|
449
|
-
# Inject epic memory if it exists (shared knowledge across the epic)
|
|
450
|
-
epic_memory = EpicMemory.read(epic["basecamp_todolist_id"])
|
|
451
|
-
if epic_memory
|
|
452
|
-
context_lines << "### Epic Memory (Shared Knowledge)"
|
|
453
|
-
context_lines << ""
|
|
454
|
-
context_lines << "The following is shared knowledge accumulated from previous tasks in this epic."
|
|
455
|
-
context_lines << "Reference this when making implementation decisions."
|
|
456
|
-
context_lines << ""
|
|
457
|
-
context_lines << "```markdown"
|
|
458
|
-
context_lines << epic_memory.lines.first(50).join # Limit to first 50 lines to avoid context overflow
|
|
459
|
-
if epic_memory.lines.size > 50
|
|
460
|
-
context_lines << "... (truncated — full file at ~/.brainiac/brain/memory/epics/epic-#{epic['basecamp_todolist_id']}.md)"
|
|
461
|
-
end
|
|
462
|
-
context_lines << "```"
|
|
463
|
-
context_lines << ""
|
|
464
|
-
end
|
|
465
|
-
|
|
466
409
|
# List completed tasks with their memory files for reference
|
|
467
410
|
completed_tasks = tasks.select { |t| t["status"] == "complete" }
|
|
468
411
|
if completed_tasks.any?
|
|
@@ -492,8 +435,9 @@ module Brainiac
|
|
|
492
435
|
# Final decision mode — agent reads gate feedback and decides
|
|
493
436
|
if current_task["awaiting_final_decision"]
|
|
494
437
|
pr_number = current_task["pr_number"]
|
|
495
|
-
|
|
496
|
-
|
|
438
|
+
gate_agents = ReviewGate.gate_states(current_task).values
|
|
439
|
+
.select { |state| state["status"] == "approved" }
|
|
440
|
+
.map { |state| state["agent"] }.join(", ")
|
|
497
441
|
|
|
498
442
|
context_lines << ""
|
|
499
443
|
context_lines << "## ⚡ FINAL DECISION REQUIRED"
|
|
@@ -524,17 +468,12 @@ module Brainiac
|
|
|
524
468
|
end
|
|
525
469
|
|
|
526
470
|
# Return the epic branch as the worktree base for cards in an active epic.
|
|
527
|
-
# If the epic branch doesn't exist for this task's project yet, create it lazily.
|
|
528
471
|
def register_resolve_base_branch
|
|
529
472
|
Brainiac.on(:resolve_base_branch) do |ctx|
|
|
530
473
|
card_number = ctx[:card_number]
|
|
531
474
|
next unless card_number
|
|
532
475
|
|
|
533
476
|
branch = EpicBranch.epic_branch_for_card(card_number)
|
|
534
|
-
|
|
535
|
-
# Lazy creation: if no branch exists for this task's project, create one now
|
|
536
|
-
branch ||= EpicBranch.ensure_epic_branch_for_card(card_number)
|
|
537
|
-
|
|
538
477
|
next unless branch
|
|
539
478
|
|
|
540
479
|
"origin/#{branch}"
|
|
@@ -542,15 +481,12 @@ module Brainiac
|
|
|
542
481
|
end
|
|
543
482
|
|
|
544
483
|
# Return the epic branch as the PR target for cards in an active epic.
|
|
545
|
-
# Same lazy-creation logic as resolve_base_branch.
|
|
546
484
|
def register_resolve_pr_target
|
|
547
485
|
Brainiac.on(:resolve_pr_target) do |ctx|
|
|
548
486
|
card_number = ctx[:card_number]
|
|
549
487
|
next unless card_number
|
|
550
488
|
|
|
551
|
-
|
|
552
|
-
branch ||= EpicBranch.ensure_epic_branch_for_card(card_number)
|
|
553
|
-
branch
|
|
489
|
+
EpicBranch.epic_branch_for_card(card_number)
|
|
554
490
|
end
|
|
555
491
|
end
|
|
556
492
|
|
|
@@ -647,7 +583,7 @@ module Brainiac
|
|
|
647
583
|
task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
|
|
648
584
|
return unless task
|
|
649
585
|
|
|
650
|
-
task
|
|
586
|
+
TaskState.transition!(task, :submit_for_review, triggered_by: "pr_merge_wait")
|
|
651
587
|
task["review_started_at"] = Time.now.iso8601
|
|
652
588
|
epic["updated_at"] = Time.now.iso8601
|
|
653
589
|
save_epic_state(epic)
|
|
@@ -675,37 +611,18 @@ module Brainiac
|
|
|
675
611
|
github_repo = project_config&.dig("github_repo")
|
|
676
612
|
|
|
677
613
|
# Check if PR is already merged (handles manual merges, webhook misses, etc.)
|
|
678
|
-
if github_repo && pr_number
|
|
614
|
+
if github_repo && pr_number
|
|
679
615
|
pr_state, = Open3.capture2("gh", "pr", "view", pr_number.to_s, "--repo", github_repo, "--json", "state", "-q", ".state")
|
|
680
616
|
if pr_state.strip == "MERGED"
|
|
681
617
|
LOG.info "[Basecamp:Hooks] PR ##{pr_number} already merged — marking card ##{card_number} complete" if defined?(LOG)
|
|
682
618
|
Orchestrator.on_card_completed(card_number)
|
|
683
619
|
return
|
|
684
620
|
end
|
|
685
|
-
|
|
686
|
-
# SAFEGUARD: Verify PR targets the epic branch (not main/master).
|
|
687
|
-
# If epic_branches is populated and the PR targets the wrong base, retarget it.
|
|
688
|
-
epic_branches = epic["epic_branches"] || {}
|
|
689
|
-
expected_base = epic_branches[project_key] || epic_branches.values.first
|
|
690
|
-
if expected_base
|
|
691
|
-
actual_base, = Open3.capture2("gh", "pr", "view", pr_number.to_s, "--repo", github_repo, "--json", "baseRefName", "-q",
|
|
692
|
-
".baseRefName")
|
|
693
|
-
actual_base = actual_base.strip
|
|
694
|
-
if !actual_base.empty? && actual_base != expected_base
|
|
695
|
-
LOG.warn "[Basecamp:Hooks] PR ##{pr_number} targets '#{actual_base}' but expected '#{expected_base}' — retargeting" if defined?(LOG)
|
|
696
|
-
_, stderr, status = Open3.capture3("gh", "pr", "edit", pr_number.to_s, "--repo", github_repo, "--base", expected_base)
|
|
697
|
-
if status.success?
|
|
698
|
-
LOG.info "[Basecamp:Hooks] Retargeted PR ##{pr_number} to '#{expected_base}'" if defined?(LOG)
|
|
699
|
-
elsif defined?(LOG)
|
|
700
|
-
LOG.error "[Basecamp:Hooks] Failed to retarget PR ##{pr_number}: #{stderr.strip}"
|
|
701
|
-
end
|
|
702
|
-
end
|
|
703
|
-
end
|
|
704
621
|
end
|
|
705
622
|
|
|
706
623
|
# Mark task as awaiting final decision
|
|
707
624
|
task["awaiting_final_decision"] = true
|
|
708
|
-
task
|
|
625
|
+
TaskState.transition!(task, :approve, triggered_by: "all_gates_approved", guard: ReviewGate.all_gates_passed?(task))
|
|
709
626
|
epic["updated_at"] = Time.now.iso8601
|
|
710
627
|
save_epic_state(epic)
|
|
711
628
|
|
|
@@ -737,19 +654,9 @@ module Brainiac
|
|
|
737
654
|
end
|
|
738
655
|
|
|
739
656
|
# Build prompt for final decision
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
# Determine expected target branch for the prompt
|
|
744
|
-
epic_branches = epic["epic_branches"] || {}
|
|
745
|
-
expected_base = epic_branches[project_key] || epic_branches.values.first
|
|
746
|
-
target_note = if expected_base
|
|
747
|
-
"\n**IMPORTANT:** This PR should target `#{expected_base}` (the epic branch). " \
|
|
748
|
-
"If `gh pr view #{pr_number} --json baseRefName` shows a different base, " \
|
|
749
|
-
"do NOT merge — report the mismatch instead.\n"
|
|
750
|
-
else
|
|
751
|
-
""
|
|
752
|
-
end
|
|
657
|
+
gate_agents = ReviewGate.gate_states(task).values
|
|
658
|
+
.select { |state| state["status"] == "approved" }
|
|
659
|
+
.map { |state| state["agent"] }.join(", ")
|
|
753
660
|
|
|
754
661
|
prompt = <<~PROMPT
|
|
755
662
|
## Final Decision Required — Fizzy Card ##{card_number}
|
|
@@ -762,7 +669,7 @@ module Brainiac
|
|
|
762
669
|
```
|
|
763
670
|
gh pr merge #{pr_number} --squash --delete-branch
|
|
764
671
|
```
|
|
765
|
-
|
|
672
|
+
|
|
766
673
|
Note: You cannot self-approve PRs you authored. Merge directly since gates have approved.
|
|
767
674
|
|
|
768
675
|
After merging, update the Fizzy card with a brief status comment.
|
|
@@ -805,148 +712,16 @@ module Brainiac
|
|
|
805
712
|
end
|
|
806
713
|
end
|
|
807
714
|
|
|
808
|
-
# Register session for
|
|
715
|
+
# Register session in SessionRegistry for liveness tracking
|
|
809
716
|
if pid
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
Object.send(:register_session, card_key, pid, log_file: log_file, agent_name: agent_name)
|
|
814
|
-
end
|
|
815
|
-
LOG.info "[Basecamp:Hooks] Spawned #{agent_name} (pid #{pid}) for final decision on card ##{card_number}" if defined?(LOG)
|
|
816
|
-
end
|
|
817
|
-
end
|
|
818
|
-
|
|
819
|
-
# Dispatch the implementation agent directly (spawns via run_agent) to address
|
|
820
|
-
# changes_requested feedback. Used by resume and health-check when the agent is
|
|
821
|
-
# no longer running but the Fizzy card is still assigned (so safe_assign_card
|
|
822
|
-
# would bail out with "already assigned, skipping").
|
|
823
|
-
#
|
|
824
|
-
# @param epic [Hash] Epic state
|
|
825
|
-
# @param task [Hash] Task within the epic
|
|
826
|
-
# @return [void]
|
|
827
|
-
def dispatch_impl_directly(epic, task)
|
|
828
|
-
card_number = task["fizzy_card"]
|
|
829
|
-
agent_name = epic["agent"]
|
|
830
|
-
pr_number = task["pr_number"]
|
|
831
|
-
project_key = task["project"]
|
|
832
|
-
card_key = "changes-requested-#{card_number}"
|
|
833
|
-
|
|
834
|
-
# Guard: don't spawn a duplicate if a session is already running for this task
|
|
835
|
-
session_alive = if defined?(session_active?)
|
|
836
|
-
session_active?(card_key)
|
|
837
|
-
elsif Object.respond_to?(:session_active?, true)
|
|
838
|
-
Object.send(:session_active?, card_key)
|
|
839
|
-
else
|
|
840
|
-
false
|
|
841
|
-
end
|
|
842
|
-
|
|
843
|
-
if session_alive
|
|
844
|
-
LOG.info "[Basecamp:Hooks] Session already active for #{card_key} — skipping dispatch" if defined?(LOG)
|
|
845
|
-
return
|
|
846
|
-
end
|
|
847
|
-
|
|
848
|
-
LOG.info "[Basecamp:Hooks] Direct-dispatching #{agent_name} for changes_requested on card ##{card_number}" if defined?(LOG)
|
|
849
|
-
|
|
850
|
-
# Get project config
|
|
851
|
-
projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
|
|
852
|
-
projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
|
|
853
|
-
project_config = projects[project_key]
|
|
854
|
-
|
|
855
|
-
unless project_config
|
|
856
|
-
LOG.error "[Basecamp:Hooks] No project config for '#{project_key}' — cannot direct-dispatch" if defined?(LOG)
|
|
857
|
-
return
|
|
858
|
-
end
|
|
859
|
-
|
|
860
|
-
repo_path = project_config["repo_path"]
|
|
861
|
-
unless repo_path
|
|
862
|
-
LOG.error "[Basecamp:Hooks] No repo_path for project '#{project_key}' — cannot direct-dispatch" if defined?(LOG)
|
|
863
|
-
return
|
|
864
|
-
end
|
|
865
|
-
|
|
866
|
-
# Ensure pr_number is set — look it up if missing.
|
|
867
|
-
# This is critical for the pr_synchronized hook to re-trigger gates after fixes.
|
|
868
|
-
unless pr_number
|
|
869
|
-
found_pr = find_pr_number(repo_path: repo_path, branch: "fizzy-#{card_number}-*")
|
|
870
|
-
if found_pr
|
|
871
|
-
pr_number = found_pr
|
|
872
|
-
task["pr_number"] = pr_number
|
|
873
|
-
task["pr_repo"] = project_config["github_repo"]
|
|
874
|
-
save_epic_state(epic)
|
|
875
|
-
LOG.info "[Basecamp:Hooks] Backfilled pr_number=#{pr_number} for card ##{card_number} during direct dispatch" if defined?(LOG)
|
|
876
|
-
elsif defined?(LOG)
|
|
877
|
-
LOG.warn "[Basecamp:Hooks] Could not find PR for card ##{card_number} — gates won't re-trigger after fixes"
|
|
878
|
-
end
|
|
879
|
-
end
|
|
880
|
-
|
|
881
|
-
# Find the worktree for this card
|
|
882
|
-
work_items_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "work_items.json")
|
|
883
|
-
worktree_path = repo_path # Default to main repo
|
|
884
|
-
if File.exist?(work_items_file)
|
|
885
|
-
work_items = JSON.parse(File.read(work_items_file))
|
|
886
|
-
work_item = work_items.values.find { |wi| wi.dig("sources", "fizzy", "card_number") == card_number }
|
|
887
|
-
worktree_path = work_item["worktree"] if work_item&.dig("worktree")
|
|
888
|
-
end
|
|
889
|
-
|
|
890
|
-
# Build prompt
|
|
891
|
-
changers = (task["changes_requested_by"] || []).join(", ")
|
|
892
|
-
prompt = <<~PROMPT
|
|
893
|
-
## Changes Requested — Fizzy Card ##{card_number}
|
|
894
|
-
|
|
895
|
-
Review gates have requested changes on your PR#{" ##{pr_number}" if pr_number}.
|
|
896
|
-
Reviewers who requested changes: #{changers}
|
|
897
|
-
|
|
898
|
-
Your job:
|
|
899
|
-
1. Read the review feedback: #{pr_number ? "`gh pr view #{pr_number} --comments`" : 'check the Fizzy card comments'}
|
|
900
|
-
2. Address all requested changes
|
|
901
|
-
3. Commit and push your fixes
|
|
902
|
-
|
|
903
|
-
After pushing, update the Fizzy card with a brief status comment noting what you fixed.
|
|
904
|
-
PROMPT
|
|
905
|
-
|
|
906
|
-
# Resolve GitHub App token so agent's `gh` commands run as their bot identity
|
|
907
|
-
github_repo = project_config&.dig("github_repo")
|
|
908
|
-
agent_env = github_repo ? ReviewGate.send(:resolve_agent_github_env, agent_name, github_repo) : {}
|
|
909
|
-
|
|
910
|
-
# Spawn the agent directly
|
|
911
|
-
pid = nil
|
|
912
|
-
log_file = nil
|
|
913
|
-
|
|
914
|
-
begin
|
|
915
|
-
pid, log_file = method(:run_agent).call(
|
|
916
|
-
prompt,
|
|
917
|
-
project_config: project_config,
|
|
918
|
-
chdir: worktree_path,
|
|
919
|
-
log_name: "changes-requested-#{card_number}",
|
|
717
|
+
SessionRegistry.register_session(
|
|
718
|
+
card_key, pid,
|
|
719
|
+
log_file: log_file,
|
|
920
720
|
agent_name: agent_name,
|
|
921
|
-
|
|
922
|
-
card_number: card_number
|
|
923
|
-
env: agent_env
|
|
721
|
+
epic_id: epic["id"],
|
|
722
|
+
card_number: card_number
|
|
924
723
|
)
|
|
925
|
-
|
|
926
|
-
if Object.respond_to?(:run_agent, true)
|
|
927
|
-
pid, log_file = Object.send(:run_agent,
|
|
928
|
-
prompt,
|
|
929
|
-
project_config: project_config,
|
|
930
|
-
chdir: worktree_path,
|
|
931
|
-
log_name: "changes-requested-#{card_number}",
|
|
932
|
-
agent_name: agent_name,
|
|
933
|
-
source: :basecamp,
|
|
934
|
-
card_number: card_number,
|
|
935
|
-
env: agent_env)
|
|
936
|
-
else
|
|
937
|
-
LOG.warn "[Basecamp:Hooks] run_agent not available — direct impl dispatch skipped" if defined?(LOG)
|
|
938
|
-
return
|
|
939
|
-
end
|
|
940
|
-
end
|
|
941
|
-
|
|
942
|
-
# Register session for waybar visibility
|
|
943
|
-
if pid
|
|
944
|
-
if defined?(register_session)
|
|
945
|
-
register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
|
|
946
|
-
elsif Object.respond_to?(:register_session, true)
|
|
947
|
-
Object.send(:register_session, card_key, pid, log_file: log_file, agent_name: agent_name)
|
|
948
|
-
end
|
|
949
|
-
LOG.info "[Basecamp:Hooks] Spawned #{agent_name} (pid #{pid}) for changes_requested on card ##{card_number}" if defined?(LOG)
|
|
724
|
+
LOG.info "[Basecamp:Hooks] Spawned #{agent_name} (pid #{pid}) for final decision on card ##{card_number}" if defined?(LOG)
|
|
950
725
|
end
|
|
951
726
|
end
|
|
952
727
|
|
|
@@ -994,12 +769,61 @@ module Brainiac
|
|
|
994
769
|
Orchestrator.on_card_completed(card_number)
|
|
995
770
|
else
|
|
996
771
|
LOG.warn "[Basecamp:Hooks] Could not merge card ##{card_number} — manual intervention needed" if defined?(LOG)
|
|
997
|
-
task
|
|
772
|
+
TaskState.transition!(task, :merge_failed, triggered_by: "epic_branch_merge_failed")
|
|
998
773
|
save_epic_state(epic)
|
|
999
774
|
end
|
|
1000
775
|
end
|
|
1001
776
|
end
|
|
1002
777
|
|
|
778
|
+
# Start an implementation agent even when Fizzy still considers the
|
|
779
|
+
# card assigned. Re-assigning a card is intentionally a no-op in that
|
|
780
|
+
# situation, which used to leave recovery loops unable to make progress.
|
|
781
|
+
def dispatch_impl_directly(epic, task)
|
|
782
|
+
card_number = task["fizzy_card"]
|
|
783
|
+
session_id = SessionRegistry.implementation_session_id(card_number)
|
|
784
|
+
return if SessionRegistry.alive?(session_id)
|
|
785
|
+
|
|
786
|
+
project_key = task["project"]
|
|
787
|
+
projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
|
|
788
|
+
projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
|
|
789
|
+
project_config = projects[project_key]
|
|
790
|
+
return unless project_config && project_config["repo_path"]
|
|
791
|
+
|
|
792
|
+
agent_name = Orchestrator.send(:resolve_agent_for_project, project_key) || epic["agent"]
|
|
793
|
+
prompt = <<~PROMPT
|
|
794
|
+
## Changes Requested — Fizzy Card ##{card_number}
|
|
795
|
+
|
|
796
|
+
Review feedback needs attention on PR ##{task['pr_number']}. Read the review,
|
|
797
|
+
make the requested fixes, commit and push them, then update the Fizzy card.
|
|
798
|
+
PROMPT
|
|
799
|
+
github_repo = project_config["github_repo"]
|
|
800
|
+
agent_env = github_repo ? ReviewGate.send(:resolve_agent_github_env, agent_name, github_repo) : {}
|
|
801
|
+
pid, log_file = method(:run_agent).call(
|
|
802
|
+
prompt,
|
|
803
|
+
project_config: project_config,
|
|
804
|
+
chdir: project_config["repo_path"],
|
|
805
|
+
log_name: session_id,
|
|
806
|
+
agent_name: agent_name,
|
|
807
|
+
source: :basecamp,
|
|
808
|
+
card_number: card_number,
|
|
809
|
+
env: agent_env
|
|
810
|
+
)
|
|
811
|
+
return unless pid
|
|
812
|
+
|
|
813
|
+
SessionRegistry.register_session(
|
|
814
|
+
session_id,
|
|
815
|
+
pid,
|
|
816
|
+
log_file: log_file,
|
|
817
|
+
agent_name: agent_name,
|
|
818
|
+
epic_id: epic["id"],
|
|
819
|
+
card_number: card_number
|
|
820
|
+
)
|
|
821
|
+
rescue NameError
|
|
822
|
+
LOG.warn "[Basecamp:Hooks] run_agent unavailable — implementation recovery skipped" if defined?(LOG)
|
|
823
|
+
rescue StandardError => e
|
|
824
|
+
LOG.error "[Basecamp:Hooks] Direct implementation dispatch failed: #{e.message}" if defined?(LOG)
|
|
825
|
+
end
|
|
826
|
+
|
|
1003
827
|
# Find a PR number by branch name pattern.
|
|
1004
828
|
def find_pr_number(repo_path:, branch:)
|
|
1005
829
|
# Try exact match first
|