brainiac-basecamp 0.0.17 → 0.0.19

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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require "time"
4
5
 
5
6
  module Brainiac
6
7
  module Plugins
@@ -29,6 +30,7 @@ module Brainiac
29
30
  # Gates can also be triggered by a Fizzy card tag "review-gates" for non-epic PRs.
30
31
  module ReviewGate
31
32
  BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
33
+ RESPONDED_STATUSES = %w[approved changes_requested].freeze
32
34
 
33
35
  class << self
34
36
  # Get the configured review gates as normalized hashes.
@@ -64,11 +66,38 @@ module Brainiac
64
66
  def all_gates_passed?(task)
65
67
  return true unless enabled?
66
68
 
67
- approvals = task["gate_approvals"] || []
68
- required_agents = gates.map { |g| g["agent"].downcase }
69
- approved_agents = approvals.map { |a| a["agent"].downcase }
69
+ gate_states(task).values.all? { |state| state["status"] == "approved" }
70
+ end
71
+
72
+ def all_gates_responded?(task)
73
+ return true unless enabled?
74
+
75
+ gate_states(task).values.all? { |state| RESPONDED_STATUSES.include?(state["status"]) }
76
+ end
70
77
 
71
- required_agents.all? { |agent| approved_agents.include?(agent) }
78
+ def changes_requested?(task)
79
+ gate_states(task).values.any? { |state| state["status"] == "changes_requested" }
80
+ end
81
+
82
+ def responded_count(task)
83
+ gate_states(task).values.count { |state| RESPONDED_STATUSES.include?(state["status"]) }
84
+ end
85
+
86
+ # Each configured gate gets one durable state record, keyed by agent name.
87
+ # This also migrates persisted state from releases that used flat arrays.
88
+ #
89
+ # NOTE: This method intentionally MUTATES the task hash on read — it lazily
90
+ # initializes gate entries for any newly-configured gates. This means adding
91
+ # a gate to config and calling gate_states on a persisted task will insert a
92
+ # new "pending" entry automatically. Do not refactor into a pure reader.
93
+ def gate_states(task)
94
+ migrate_legacy_gate_state!(task)
95
+ task["gate_states"] ||= {}
96
+ gates.each do |gate|
97
+ key = gate_key(gate["agent"])
98
+ task["gate_states"][key] ||= new_gate_state(gate)
99
+ end
100
+ task["gate_states"]
72
101
  end
73
102
 
74
103
  # Sync gate approvals from GitHub PR reviews (self-healing).
@@ -110,22 +139,15 @@ module Brainiac
110
139
  next unless review_state
111
140
 
112
141
  if review_state == "approved"
113
- # Record approval if not already recorded
114
- unless (task["gate_approvals"] || []).any? { |a| a["agent"].downcase == agent.downcase }
142
+ state = gate_state(task, agent, role)
143
+ was_changes_requested = state["status"] == "changes_requested"
144
+ unless state["status"] == "approved"
115
145
  record_approval(task, agent: agent, role: role)
116
146
  changes[:approvals_added] << agent
117
147
  end
118
- # Clear from changes_requested if present
119
- if task["changes_requested_by"]&.include?(agent)
120
- task["changes_requested_by"].delete(agent)
121
- changes[:changes_cleared] << agent
122
- end
148
+ changes[:changes_cleared] << agent if was_changes_requested
123
149
  elsif review_state == "changes_requested"
124
- # Remove any stale approval
125
- task["gate_approvals"]&.reject! { |a| a["agent"].downcase == agent.downcase }
126
- # Track changes_requested
127
- task["changes_requested_by"] ||= []
128
- task["changes_requested_by"] << agent unless task["changes_requested_by"].include?(agent)
150
+ record_changes_requested(task, agent: agent, role: role)
129
151
  end
130
152
  end
131
153
 
@@ -140,22 +162,31 @@ module Brainiac
140
162
  # @param agent [String] Agent that approved
141
163
  # @param role [String] Gate role
142
164
  def record_approval(task, agent:, role:)
143
- task["gate_approvals"] ||= []
144
- # Don't duplicate
145
- return if task["gate_approvals"].any? { |a| a["agent"].downcase == agent.downcase }
146
-
147
- task["gate_approvals"] << {
148
- "agent" => agent,
149
- "role" => role,
150
- "approved_at" => Time.now.iso8601
151
- }
165
+ state = gate_state(task, agent, role)
166
+ changed = state["status"] != "approved"
167
+ state["status"] = "approved"
168
+ state["responded_at"] = Time.now.iso8601 if changed || state["responded_at"].nil?
169
+ state
170
+ end
171
+
172
+ def record_changes_requested(task, agent:, role:)
173
+ state = gate_state(task, agent, role)
174
+ changed = state["status"] != "changes_requested"
175
+ state["status"] = "changes_requested"
176
+ state["responded_at"] = Time.now.iso8601 if changed || state["responded_at"].nil?
177
+ state
152
178
  end
153
179
 
154
180
  # Reset gate approvals (when changes are requested and code is updated).
155
181
  #
156
182
  # @param task [Hash] Task state (mutated in place)
157
183
  def reset_approvals(task)
158
- task["gate_approvals"] = []
184
+ gate_states(task).each_value do |state|
185
+ state["status"] = "pending"
186
+ state["dispatched_at"] = nil
187
+ state["responded_at"] = nil
188
+ state["dispatch_count"] = 0
189
+ end
159
190
  end
160
191
 
161
192
  # Dispatch all gate agents to review a PR in parallel.
@@ -166,23 +197,17 @@ module Brainiac
166
197
  # @param pr_number [Integer, String] PR number
167
198
  # @param repo_name [String] e.g. "stowzilla/brainiac-basecamp"
168
199
  # @param repo_path [String] Local repo path
169
- # @param is_rereview [Boolean] True if this is a re-review after changes
170
200
  # @return [Array<String>] Agent names dispatched
171
- def dispatch_gates(epic:, task:, pr_number:, repo_name:, repo_path:, is_rereview: false)
201
+ def dispatch_gates(epic:, task:, pr_number:, repo_name:, repo_path:)
172
202
  dispatched = []
173
203
 
174
- # Get current HEAD SHA for tracking incremental reviews
175
- current_sha = get_pr_head_sha(pr_number: pr_number, repo_path: repo_path)
204
+ gate_states(task).each_value do |state|
205
+ next unless state["status"] == "pending"
176
206
 
177
- # Determine if this is a re-review and what SHA to diff from
178
- last_reviewed_sha = task["last_reviewed_sha"]
179
- diff_from_sha = is_rereview && last_reviewed_sha ? last_reviewed_sha : nil
207
+ agent_name = state["agent"]
208
+ role = state["role"] || "review"
180
209
 
181
- gates.each do |gate|
182
- agent_name = gate["agent"]
183
- role = gate["role"] || "review"
184
-
185
- LOG.info "[Basecamp:ReviewGate] Dispatching #{agent_name} (#{role}) to review PR ##{pr_number}#{" (incremental from #{diff_from_sha[0..7]})" if diff_from_sha}" if defined?(LOG)
210
+ LOG.info "[Basecamp:ReviewGate] Dispatching #{agent_name} (#{role}) to review PR ##{pr_number}" if defined?(LOG)
186
211
 
187
212
  # Dispatch the gate agent via brainiac-github's PR review mechanism.
188
213
  # The agent gets the PR diff and reviews it using their bot identity.
@@ -194,102 +219,32 @@ module Brainiac
194
219
  repo_name: repo_name,
195
220
  repo_path: repo_path,
196
221
  card_number: task["fizzy_card"],
197
- epic: epic,
198
- diff_from_sha: diff_from_sha
222
+ epic: epic
199
223
  )
200
224
  rescue StandardError => e
201
225
  LOG.error "[Basecamp:ReviewGate] Failed to dispatch #{agent_name}: #{e.message}" if defined?(LOG)
202
226
  end
203
227
 
204
228
  dispatched << agent_name
229
+ state["status"] = "dispatched"
230
+ state["dispatched_at"] = Time.now.iso8601
231
+ state["dispatch_count"] += 1
205
232
  end
206
233
 
207
234
  # Update task state
208
- task["status"] = "in_review"
209
- task["gates_dispatched_at"] = Time.now.iso8601
210
- task["gate_approvals"] ||= []
211
-
212
- # Track the SHA we're reviewing for incremental diff on next round
213
- task["last_reviewed_sha"] = current_sha if current_sha
235
+ TaskState.transition!(task, :submit_for_review, triggered_by: "review_gate_dispatch")
214
236
 
215
237
  dispatched
216
238
  end
217
239
 
218
- # Dispatch only gate agents that have NOT yet responded (no approval, no changes_requested).
219
- # Used during resume/health-check when some gates responded but others didn't.
220
- #
221
- # Tracks per-gate re-dispatch count to prevent infinite loops when a gate agent
222
- # keeps crashing. After MAX_GATE_REDISPATCH_RETRIES, stops retrying that gate.
223
- #
224
- # IMPORTANT: Does NOT overwrite gates_dispatched_at — that timestamp represents
225
- # the original dispatch and is used for stale detection. Overwriting it would
226
- # reset the timeout window on every partial re-dispatch, causing infinite loops.
227
- #
228
- # @param epic [Hash] Epic state
229
- # @param task [Hash] Task state
230
- # @param pr_number [Integer, String] PR number
231
- # @param repo_name [String] e.g. "stowzilla/brainiac-basecamp"
232
- # @param repo_path [String] Local repo path
233
- # @param is_rereview [Boolean] True if this is a re-review after implementation fixes
234
- # @return [Array<String>] Agent names dispatched
235
- def dispatch_missing_gates(epic:, task:, pr_number:, repo_name:, repo_path:, is_rereview: false)
236
- responded_agents = Set.new
237
- (task["gate_approvals"] || []).each { |a| responded_agents << a["agent"].downcase }
238
- (task["changes_requested_by"] || []).each { |a| responded_agents << a.downcase }
239
-
240
- missing_gates = gates.reject { |g| responded_agents.include?(g["agent"].downcase) }
241
- return [] if missing_gates.empty?
242
-
243
- # Track per-gate re-dispatch counts to prevent infinite loops
244
- task["gate_redispatch_counts"] ||= {}
245
-
246
- # For re-reviews, determine incremental diff range
247
- diff_from_sha = is_rereview && task["last_reviewed_sha"] ? task["last_reviewed_sha"] : nil
248
- current_sha = get_pr_head_sha(pr_number: pr_number, repo_path: repo_path)
249
-
250
- dispatched = []
251
-
252
- missing_gates.each do |gate|
253
- agent_name = gate["agent"]
254
- role = gate["role"] || "review"
255
-
256
- # Check retry count — stop if we've exceeded the max
257
- retries = task["gate_redispatch_counts"][agent_name.downcase] || 0
258
- if retries >= MAX_GATE_REDISPATCH_RETRIES
259
- LOG.warn "[Basecamp:ReviewGate] Gate #{agent_name} exceeded max re-dispatch retries (#{retries}/#{MAX_GATE_REDISPATCH_RETRIES}) — skipping" if defined?(LOG)
260
- next
261
- end
262
-
263
- LOG.info "[Basecamp:ReviewGate] Re-dispatching missing gate #{agent_name} (#{role}) to review PR ##{pr_number} (retry #{retries + 1}/#{MAX_GATE_REDISPATCH_RETRIES})#{' (incremental)' if diff_from_sha}" if defined?(LOG)
264
-
265
- task["gate_redispatch_counts"][agent_name.downcase] = retries + 1
266
-
267
- Thread.new do
268
- dispatch_agent_for_review(
269
- agent_name: agent_name,
270
- role: role,
271
- pr_number: pr_number,
272
- repo_name: repo_name,
273
- repo_path: repo_path,
274
- card_number: task["fizzy_card"],
275
- epic: epic,
276
- diff_from_sha: diff_from_sha
277
- )
278
- rescue StandardError => e
279
- LOG.error "[Basecamp:ReviewGate] Failed to dispatch #{agent_name}: #{e.message}" if defined?(LOG)
280
- end
281
-
282
- dispatched << agent_name
240
+ # Re-dispatch only stale records. A gate that has used its full retry
241
+ # budget becomes explicitly timed_out instead of silently disappearing.
242
+ def redispatch_stale_gates(epic:, task:, pr_number:, repo_name:, repo_path:)
243
+ stale_gate_states(task).each do |state|
244
+ state["status"] = state["dispatch_count"] > MAX_GATE_REDISPATCH_RETRIES ? "timed_out" : "pending"
283
245
  end
284
246
 
285
- # Record when this partial re-dispatch happened (for diagnostics)
286
- # but do NOT overwrite gates_dispatched_at — that's the original timestamp
287
- task["last_redispatch_at"] = Time.now.iso8601
288
-
289
- # Update last_reviewed_sha for next round
290
- task["last_reviewed_sha"] = current_sha if current_sha
291
-
292
- dispatched
247
+ dispatch_gates(epic: epic, task: task, pr_number: pr_number, repo_name: repo_name, repo_path: repo_path)
293
248
  end
294
249
 
295
250
  # Build the summary comment for Fizzy after all gates pass and merge completes.
@@ -298,7 +253,7 @@ module Brainiac
298
253
  # @param pr_url [String] PR URL
299
254
  # @return [String] HTML comment for Fizzy
300
255
  def build_gate_summary_comment(task, pr_url:)
301
- approvals = task["gate_approvals"] || []
256
+ approvals = gate_states(task).values.select { |state| state["status"] == "approved" }
302
257
  lines = []
303
258
  lines << "<p>✅ <strong>All review gates passed</strong> — merged into epic branch.</p>"
304
259
  lines << "<p><a href=\"#{pr_url}\">PR Link</a></p>"
@@ -321,33 +276,72 @@ module Brainiac
321
276
 
322
277
  private
323
278
 
324
- # Get the HEAD SHA of a PR (for tracking incremental reviews).
325
- #
326
- # @param pr_number [Integer, String] PR number
327
- # @param repo_path [String] Local repo path
328
- # @return [String, nil] Commit SHA or nil if failed
329
- def get_pr_head_sha(pr_number:, repo_path:)
330
- stdout, _, status = Open3.capture3(
331
- "gh", "pr", "view", pr_number.to_s,
332
- "--json", "headRefOid",
333
- "--jq", ".headRefOid",
334
- chdir: repo_path
335
- )
336
- return nil unless status.success?
279
+ def gate_key(agent)
280
+ agent.to_s.downcase
281
+ end
337
282
 
338
- sha = stdout.strip
339
- sha.empty? ? nil : sha
340
- rescue StandardError => e
341
- LOG.warn "[Basecamp:ReviewGate] Failed to get PR head SHA: #{e.message}" if defined?(LOG)
342
- nil
283
+ def new_gate_state(gate)
284
+ {
285
+ "agent" => gate["agent"],
286
+ "role" => gate["role"] || "review",
287
+ "status" => "pending",
288
+ "dispatched_at" => nil,
289
+ "responded_at" => nil,
290
+ "dispatch_count" => 0
291
+ }
292
+ end
293
+
294
+ def gate_state(task, agent, role = nil)
295
+ gate_states(task)[gate_key(agent)] ||= new_gate_state("agent" => agent, "role" => role)
296
+ end
297
+
298
+ def stale_gate_states(task)
299
+ gate_states(task).values.select do |state|
300
+ next false unless state["status"] == "dispatched" && state["dispatched_at"]
301
+ next false if SessionRegistry.alive?("gate-#{state['agent'].to_s.downcase}-#{task['fizzy_card']}")
302
+
303
+ Time.now - Time.parse(state["dispatched_at"]) > STALE_DISPATCH_TIMEOUT
304
+ end
305
+ end
306
+
307
+ def migrate_legacy_gate_state!(task)
308
+ return if task["gate_states"]
309
+
310
+ task["gate_states"] = {}
311
+ approvals = task["gate_approvals"] || []
312
+ changes_requested = (task["changes_requested_by"] || []).map(&:downcase)
313
+ dispatched_at = task["gates_dispatched_at"]
314
+ redispatch_counts = task["gate_redispatch_counts"] || {}
315
+
316
+ gates.each do |gate|
317
+ key = gate_key(gate["agent"])
318
+ approval = approvals.find { |entry| gate_key(entry["agent"]) == key }
319
+ status = if approval
320
+ "approved"
321
+ elsif changes_requested.include?(key)
322
+ "changes_requested"
323
+ elsif dispatched_at
324
+ "dispatched"
325
+ else
326
+ "pending"
327
+ end
328
+ task["gate_states"][key] = new_gate_state(gate).merge(
329
+ "status" => status,
330
+ "dispatched_at" => dispatched_at,
331
+ "responded_at" => approval&.fetch("approved_at", nil),
332
+ "dispatch_count" => redispatch_counts[key] || (dispatched_at ? 1 : 0)
333
+ )
334
+ end
335
+
336
+ task.delete("gate_approvals")
337
+ task.delete("changes_requested_by")
338
+ task.delete("gates_dispatched_at")
339
+ task.delete("gate_redispatch_counts")
343
340
  end
344
341
 
345
342
  # Dispatch a single gate agent to review a PR.
346
343
  # This creates a review prompt and runs the agent in the repo directory.
347
- #
348
- # @param diff_from_sha [String, nil] If present, this is a re-review and agent should
349
- # focus on changes since this SHA (incremental review)
350
- def dispatch_agent_for_review(agent_name:, role:, pr_number:, repo_name:, repo_path:, card_number:, epic:, diff_from_sha: nil)
344
+ def dispatch_agent_for_review(agent_name:, role:, pr_number:, repo_name:, repo_path:, card_number:, epic:)
351
345
  # Build a review-specific prompt for the gate agent
352
346
  prompt = build_gate_review_prompt(
353
347
  agent_name: agent_name,
@@ -355,8 +349,7 @@ module Brainiac
355
349
  pr_number: pr_number,
356
350
  repo_name: repo_name,
357
351
  card_number: card_number,
358
- epic_title: epic["title"],
359
- diff_from_sha: diff_from_sha
352
+ epic_title: epic["title"]
360
353
  )
361
354
 
362
355
  # Resolve the agent's GitHub token for their bot identity
@@ -396,89 +389,43 @@ module Brainiac
396
389
  end
397
390
  end
398
391
 
399
- # Register session for waybar visibility
400
- if pid && defined?(register_session)
401
- register_session(card_key, pid, log_file: log_file, agent_name: agent_name)
402
- elsif pid && Object.respond_to?(:register_session, true)
403
- Object.send(:register_session, card_key, pid, log_file: log_file, agent_name: agent_name)
404
- end
392
+ # Register session in SessionRegistry for liveness tracking
393
+ return unless pid
394
+
395
+ SessionRegistry.register_session(
396
+ card_key, pid,
397
+ log_file: log_file,
398
+ agent_name: agent_name,
399
+ epic_id: epic["id"],
400
+ card_number: card_number
401
+ )
402
+ LOG.info "[Basecamp:ReviewGate] Spawned #{agent_name} (pid #{pid}) for gate review on card ##{card_number}" if defined?(LOG)
405
403
  end
406
404
 
407
405
  # Build the prompt for a gate review agent.
408
- #
409
- # @param diff_from_sha [String, nil] If present, this is an incremental re-review
410
- def build_gate_review_prompt(agent_name:, role:, pr_number:, repo_name:, card_number:, epic_title:, diff_from_sha: nil)
411
- if diff_from_sha
412
- # Incremental re-review prompt — focus only on new changes
413
- <<~PROMPT
414
- You are RE-REVIEWING PR ##{pr_number} on #{repo_name} as part of epic: "#{epic_title}".
415
- Your role: **#{role}**
416
-
417
- This is an INCREMENTAL REVIEW — you already reviewed this PR and requested changes.
418
- The implementation agent has pushed fixes. Focus on what changed.
419
-
420
- **Step 1: Read the context to understand WHY changes were made:**
421
- ```
422
- gh pr view #{pr_number}
423
- gh pr view #{pr_number} --comments
424
- git log #{diff_from_sha}..HEAD --oneline
425
- ```
426
-
427
- - `gh pr view` shows the PR description (implementation rationale)
428
- - `--comments` shows discussion and review responses
429
- - `git log` shows commit messages explaining each fix
430
-
431
- Read these BEFORE looking at the code changes.
432
-
433
- **Step 2: View the NEW changes since your last review:**
434
- ```
435
- git fetch origin && git diff #{diff_from_sha}..HEAD
436
- ```
437
-
438
- Or compare in GitHub: `#{repo_name}/compare/#{diff_from_sha[0..7]}...HEAD`
439
-
440
- If you need the full context, you can still use `gh pr diff #{pr_number}`, but prioritize
441
- reviewing the incremental changes first.
442
-
443
- Based on your role (#{role}):
444
- #{role_instructions(role)}
445
-
446
- After your review:
447
- - If the fixes address your concerns: `gh pr review #{pr_number} --approve --body "your summary"`
448
- - If more changes are needed: `gh pr review #{pr_number} --request-changes --body "what still needs fixing"`
449
-
450
- Be thorough but pragmatic. This is Fizzy card ##{card_number}.
451
-
452
- IMPORTANT RESTRICTIONS:
453
- - Do NOT open new PRs or modify code — you are a reviewer only
454
- - Do NOT comment on the Fizzy card — your review goes on GitHub only
455
- - Do NOT use the fizzy CLI at all
456
- PROMPT
457
- else
458
- # Initial full review prompt
459
- <<~PROMPT
460
- You are reviewing PR ##{pr_number} on #{repo_name} as part of epic: "#{epic_title}".
461
- Your role: **#{role}**
406
+ def build_gate_review_prompt(agent_name:, role:, pr_number:, repo_name:, card_number:, epic_title:)
407
+ <<~PROMPT
408
+ You are reviewing PR ##{pr_number} on #{repo_name} as part of epic: "#{epic_title}".
409
+ Your role: **#{role}**
462
410
 
463
- This is a review gate — the epic cannot proceed until you approve.
411
+ This is a review gate — the epic cannot proceed until you approve.
464
412
 
465
- Review the PR changes with `gh pr diff #{pr_number}` and `gh pr view #{pr_number}`.
413
+ Review the PR changes with `gh pr diff #{pr_number}` and `gh pr view #{pr_number}`.
466
414
 
467
- Based on your role (#{role}):
468
- #{role_instructions(role)}
415
+ Based on your role (#{role}):
416
+ #{role_instructions(role)}
469
417
 
470
- After your review:
471
- - If the code meets your standards: `gh pr review #{pr_number} --approve --body "your summary"`
472
- - If changes are needed: `gh pr review #{pr_number} --request-changes --body "what needs fixing"`
418
+ After your review:
419
+ - If the code meets your standards: `gh pr review #{pr_number} --approve --body "your summary"`
420
+ - If changes are needed: `gh pr review #{pr_number} --request-changes --body "what needs fixing"`
473
421
 
474
- Be thorough but pragmatic. This is Fizzy card ##{card_number}.
422
+ Be thorough but pragmatic. This is Fizzy card ##{card_number}.
475
423
 
476
- IMPORTANT RESTRICTIONS:
477
- - Do NOT open new PRs or modify code — you are a reviewer only
478
- - Do NOT comment on the Fizzy card — your review goes on GitHub only
479
- - Do NOT use the fizzy CLI at all
480
- PROMPT
481
- end
424
+ IMPORTANT RESTRICTIONS:
425
+ - Do NOT open new PRs or modify code — you are a reviewer only
426
+ - Do NOT comment on the Fizzy card — your review goes on GitHub only
427
+ - Do NOT use the fizzy CLI at all
428
+ PROMPT
482
429
  end
483
430
 
484
431
  # Role-specific review instructions.