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.
@@ -6,9 +6,12 @@ require_relative "basecamp/metadata"
6
6
  require_relative "basecamp/config"
7
7
  require_relative "basecamp/client"
8
8
  require_relative "basecamp/epic"
9
+ require_relative "basecamp/task_state"
9
10
  require_relative "basecamp/epic_branch"
10
11
  require_relative "basecamp/epic_memory"
12
+ require_relative "basecamp/session_registry"
11
13
  require_relative "basecamp/review_gate"
14
+ require_relative "basecamp/comment_responder"
12
15
  require_relative "basecamp/orchestrator"
13
16
  require_relative "basecamp/webhook"
14
17
  require_relative "basecamp/hooks"
@@ -19,8 +22,7 @@ module Brainiac
19
22
  module Plugins
20
23
  module Basecamp
21
24
  # Maximum seconds to wait for a dispatched agent/gate to respond before
22
- # considering it stale and re-dispatching. Used across resume, health-check,
23
- # and dispatch_missing_gates.
25
+ # considering it stale and re-dispatching. Used across resume and health-check.
24
26
  STALE_DISPATCH_TIMEOUT = 300 # 5 minutes
25
27
 
26
28
  # Maximum number of times a gate will be re-dispatched for the same review
@@ -34,6 +36,10 @@ module Brainiac
34
36
  def register(app)
35
37
  Config.load!
36
38
 
39
+ # Clear all sessions on startup — PIDs from prior runs are untrustworthy
40
+ SessionRegistry.clear_all!
41
+ SessionRegistry.install_global_registration_hook!
42
+
37
43
  # Register lifecycle hooks
38
44
  Hooks.register_all!
39
45
 
@@ -49,553 +55,182 @@ module Brainiac
49
55
  LOG.info "[Basecamp] #{active.size} active epic(s) in progress"
50
56
  active.each { |e| LOG.info "[Basecamp] - #{e['title']} (#{e['tasks']&.count { |t| t['status'] == 'complete' }}/#{e['tasks']&.size} complete)" }
51
57
 
52
- # Resume active epics in background after server is ready
58
+ # Reconcile active epics in background after server is ready. The
59
+ # session registry was cleared above, so this treats all inherited
60
+ # agent sessions as dead and safely starts fresh work where needed.
53
61
  Thread.new do
54
62
  sleep 10 # Wait for server to fully start
55
- resume_active_epics(active)
63
+ reconcile_active_epics(active, triggered_by: "startup_recovery")
56
64
  rescue StandardError => e
57
- LOG.error "[Basecamp] Error resuming epics: #{e.message}" if defined?(LOG)
65
+ LOG.error "[Basecamp:Recovery] Startup reconciliation failed: #{e.message}" if defined?(LOG)
58
66
  end
59
67
  end
60
68
 
61
- # Start background health check thread for active epics
69
+ # Start the periodic recovery loop for active epics.
62
70
  start_epic_health_monitor
63
71
 
64
72
  LOG.info "[Basecamp] Plugin registered (webhook: /basecamp, review_gate: #{Config.review_gate})"
65
73
  end
66
74
 
67
- # Background thread that periodically checks for stuck epic states and auto-heals.
68
- # Runs every 90 seconds to catch:
69
- # - final_decision tasks where PR is already merged
70
- # - in_flight tasks where impl agent isn't assigned
71
- # - in_review tasks where all gates have actually approved (webhook missed)
75
+ # Background thread that periodically runs the same reconciliation used
76
+ # after startup. Recovery owns liveness reaping and state repair; normal
77
+ # hooks own the immediate event path.
72
78
  def start_epic_health_monitor
73
79
  @health_monitor_thread = Thread.new do
74
80
  loop do
75
81
  sleep 90 # Check every 90 seconds
76
82
 
77
83
  begin
78
- active_epics = Orchestrator.active_epics
79
- next if active_epics.empty?
80
-
81
- active_epics.each do |epic|
82
- health_check_epic(epic)
83
- end
84
+ reconcile_active_epics(Orchestrator.active_epics, triggered_by: "periodic_recovery")
84
85
  rescue StandardError => e
85
- LOG.error "[Basecamp:HealthCheck] Error: #{e.message}" if defined?(LOG)
86
+ LOG.error "[Basecamp:Recovery] Periodic reconciliation failed: #{e.message}" if defined?(LOG)
86
87
  end
87
88
  end
88
89
  end
89
90
  @health_monitor_thread.abort_on_exception = false
90
91
  end
91
92
 
92
- # Check an epic for stuck states and auto-heal
93
- def health_check_epic(epic)
94
- healed_any = false
95
-
96
- epic["tasks"]&.each do |task|
97
- card_number = task["fizzy_card"]
98
- status = task["status"]
99
- pr_number = task["pr_number"]
100
- project_key = task["project"]
101
-
102
- case status
103
- when "final_decision"
104
- # Check if PR is already merged — resolve pr_number first if missing/zero
105
- effective_pr = pr_number
106
- if (effective_pr.nil? || effective_pr.zero?) && project_key
107
- resolved = resolve_pr_for_task(task)
108
- if resolved
109
- task["pr_number"] = resolved[:number]
110
- task["pr_repo"] = resolved[:repo] if resolved[:repo]
111
- effective_pr = resolved[:number]
112
- LOG.info "[Basecamp:HealthCheck] Resolved PR ##{effective_pr} for card ##{card_number}" if defined?(LOG)
113
- end
114
- end
115
-
116
- if effective_pr&.positive? && project_key
117
- projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
118
- projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
119
- github_repo = projects.dig(project_key, "github_repo")
120
-
121
- if github_repo
122
- pr_state, = Open3.capture2("gh", "pr", "view", effective_pr.to_s, "--repo", github_repo, "--json", "state", "-q", ".state")
123
- if pr_state.strip == "MERGED"
124
- LOG.info "[Basecamp:HealthCheck] PR ##{effective_pr} merged but task ##{card_number} stuck in final_decision — healing" if defined?(LOG)
125
- Orchestrator.on_card_completed(card_number)
126
- healed_any = true
127
- end
128
- end
129
- end
130
-
131
- when "in_flight"
132
- # Check if impl agent session is likely dead after a restart.
133
- # Instead of just checking Fizzy assignment (stale artifact), use
134
- # dispatched_at as a liveness signal — if dispatched too long ago
135
- # without completion, assume the session died and re-dispatch.
136
- dispatched_at = task["dispatched_at"]
137
- if dispatched_at
138
- elapsed = Time.now - Time.parse(dispatched_at)
139
- # If task has been in_flight for longer than the stale timeout
140
- # AND no agent is actively running (we can't check PID, so use elapsed time)
141
- if elapsed > STALE_DISPATCH_TIMEOUT
142
- impl_agent = epic["agent"]
143
- LOG.info "[Basecamp:HealthCheck] Task ##{card_number} in_flight for #{elapsed.round}s — re-dispatching #{impl_agent}" if defined?(LOG)
144
-
145
- # Spawn the agent directly — safe_assign_card won't work if already assigned
146
- task["dispatched_at"] = Time.now.iso8601
147
- Hooks.send(:dispatch_impl_directly, epic, task)
148
- healed_any = true
149
- end
150
- elsif task["changes_requested_by"]&.any?
151
- # No dispatched_at recorded but changes were requested — legacy state.
152
- # Always re-dispatch in this case.
153
- impl_agent = epic["agent"]
154
- LOG.info "[Basecamp:HealthCheck] Task ##{card_number} in_flight with changes_requested but no dispatched_at — re-dispatching #{impl_agent}" if defined?(LOG)
155
-
156
- # Spawn the agent directly — safe_assign_card won't work if already assigned
157
- task["dispatched_at"] = Time.now.iso8601
158
- Hooks.send(:dispatch_impl_directly, epic, task)
159
- healed_any = true
160
- end
161
-
162
- when "in_review"
163
- # Check if all gates have actually approved (webhook might have missed)
164
- # Resolve pr_number if missing/zero (same pattern as final_decision)
165
- effective_pr = pr_number
166
- if (effective_pr.nil? || effective_pr.zero?) && project_key
167
- resolved = resolve_pr_for_task(task)
168
- if resolved
169
- task["pr_number"] = resolved[:number]
170
- task["pr_repo"] = resolved[:repo] if resolved[:repo]
171
- effective_pr = resolved[:number]
172
- LOG.info "[Basecamp:HealthCheck] Resolved PR ##{effective_pr} for in_review card ##{card_number}" if defined?(LOG)
173
- end
174
- end
175
-
176
- if effective_pr&.positive? && project_key && ReviewGate.enabled?
177
- projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
178
- projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
179
- repo_path = projects.dig(project_key, "repo_path")
180
- github_repo = projects.dig(project_key, "github_repo")
181
-
182
- if repo_path
183
- # Sync from GitHub to catch any missed approvals
184
- ReviewGate.sync_from_github(task, repo_path: repo_path)
185
-
186
- if ReviewGate.all_gates_passed?(task)
187
- # All approved — advance to final_decision
188
- LOG.info "[Basecamp:HealthCheck] All gates passed for ##{card_number} but still in_review — healing" if defined?(LOG)
189
- task["status"] = "final_decision"
190
- task["awaiting_final_decision"] = true
191
- Hooks.send(:save_epic_state, epic)
192
- Hooks.send(:dispatch_final_decision, epic, task, {})
193
- healed_any = true
194
- elsif task["changes_requested_by"]&.any? && pr_has_newer_commits_than_reviews?(task, repo_path: repo_path)
195
- # Changes were requested but the impl agent has already pushed fixes.
196
- # The pr_synchronized hook missed this (pr_number was 0, or status wasn't in_flight).
197
- # Reset approvals and re-dispatch gates to review the new code.
198
- LOG.info "[Basecamp:HealthCheck] PR has commits newer than last changes_requested review for ##{card_number} — re-dispatching gates" if defined?(LOG)
199
- ReviewGate.reset_approvals(task)
200
- task["changes_requested_by"] = []
201
- task["gates_dispatched_at"] = Time.now.iso8601
202
- Hooks.send(:save_epic_state, epic)
203
-
204
- Thread.new do
205
- ReviewGate.dispatch_gates(
206
- epic: epic,
207
- task: task,
208
- pr_number: effective_pr,
209
- repo_name: github_repo,
210
- repo_path: repo_path,
211
- is_rereview: true
212
- )
213
- rescue StandardError => e
214
- LOG.error "[Basecamp:HealthCheck] Gate re-dispatch failed for ##{card_number}: #{e.message}" if defined?(LOG)
215
- end
216
- healed_any = true
217
- elsif Hooks.send(:all_gates_responded?, task) && task["changes_requested_by"]&.any?
218
- # All gates responded but some requested changes — need to dispatch impl agent
219
- impl_agent = epic["agent"]
220
- card_json, = Open3.capture2("fizzy", "card", "show", card_number.to_s, "--json")
221
- card_data = JSON.parse(card_json) rescue {}
222
- assignees = card_data.dig("data", "assignees") || []
223
- assignee_names = assignees.map { |a| a["name"]&.downcase }
224
-
225
- LOG.info "[Basecamp:HealthCheck] All gates responded for ##{card_number} with changes_requested — transitioning to in_flight" if defined?(LOG)
226
- task["status"] = "in_flight"
227
- Hooks.send(:save_epic_state, epic)
228
-
229
- unless assignee_names.include?(impl_agent.downcase)
230
- LOG.info "[Basecamp:HealthCheck] Re-assigning ##{card_number} to #{impl_agent}" if defined?(LOG)
231
- fizzy_user_id = Orchestrator.send(:resolve_fizzy_user_id, impl_agent)
232
- if fizzy_user_id
233
- Open3.capture3("fizzy", "card", "assign", card_number.to_s, "--user", fizzy_user_id)
234
- end
235
- end
236
- healed_any = true
237
- elsif (task["gate_approvals"] || []).empty? && !task["changes_requested_by"]&.any?
238
- # No gate responses at all — gates may never have been dispatched or agents crashed
239
- gates_dispatched_at = task["gates_dispatched_at"]
240
- should_redispatch = gates_dispatched_at.nil? || (Time.now - Time.parse(gates_dispatched_at)) > STALE_DISPATCH_TIMEOUT
241
-
242
- if should_redispatch && github_repo
243
- LOG.info "[Basecamp:HealthCheck] No gate responses for ##{card_number} — re-dispatching review gates" if defined?(LOG)
244
- ReviewGate.dispatch_gates(
245
- epic: epic,
246
- task: task,
247
- pr_number: effective_pr,
248
- repo_name: github_repo,
249
- repo_path: repo_path
250
- )
251
- Hooks.send(:save_epic_state, epic)
252
- healed_any = true
253
- end
254
- else
255
- # Partial responses — some gates responded but others haven't
256
- gates_dispatched_at = task["gates_dispatched_at"]
257
- if gates_dispatched_at && (Time.now - Time.parse(gates_dispatched_at)) > STALE_DISPATCH_TIMEOUT
258
- responded_count = (task["gate_approvals"]&.size || 0) + (task["changes_requested_by"]&.size || 0)
259
- total_gates = ReviewGate.gates.size
260
- if responded_count.positive? && responded_count < total_gates && github_repo
261
- LOG.info "[Basecamp:HealthCheck] Partial gate response for ##{card_number} (#{responded_count}/#{total_gates}) — re-dispatching missing gates" if defined?(LOG)
262
- ReviewGate.dispatch_missing_gates(
263
- epic: epic,
264
- task: task,
265
- pr_number: effective_pr,
266
- repo_name: github_repo,
267
- repo_path: repo_path
268
- )
269
- Hooks.send(:save_epic_state, epic)
270
- healed_any = true
271
- end
272
- end
273
- end
274
- end
275
- end
93
+ # The sole restart/periodic reconciliation entry point. It deliberately
94
+ # uses the exact transitions and gate-state operations used by hooks.
95
+ def reconcile_active_epics(epics = Orchestrator.active_epics, triggered_by: "recovery")
96
+ SessionRegistry.reap_dead!
97
+ SessionRegistry.sweep!
98
+ epics.each { |epic| reconcile_epic(epic, triggered_by: triggered_by) }
99
+ end
276
100
 
277
- when "complete"
278
- # Sanity check: verify the PR is actually merged before accepting "complete" status.
279
- # Catches race conditions where rapid review cycles cause premature task completion.
280
- next unless project_key
281
-
282
- effective_pr = pr_number
283
- if effective_pr.nil? || effective_pr.zero?
284
- resolved = resolve_pr_for_task(task)
285
- if resolved
286
- task["pr_number"] = resolved[:number]
287
- task["pr_repo"] = resolved[:repo] if resolved[:repo]
288
- effective_pr = resolved[:number]
289
- end
290
- end
101
+ def reconcile_epic(epic, triggered_by: "recovery")
102
+ tasks = epic["tasks"] || []
103
+ tasks.each { |task| TaskState.migrate!(task, triggered_by: triggered_by) }
291
104
 
292
- # If we can't find a PR, we can't verify — skip (might be a no-code task)
293
- next unless effective_pr&.positive?
294
-
295
- projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
296
- projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
297
- github_repo = projects.dig(project_key, "github_repo")
298
- repo_path = projects.dig(project_key, "repo_path")
299
- next unless github_repo
300
-
301
- pr_state, = Open3.capture2("gh", "pr", "view", effective_pr.to_s, "--repo", github_repo, "--json", "state", "-q", ".state")
302
- pr_state = pr_state.strip
303
-
304
- if pr_state == "OPEN"
305
- # PR is NOT merged — task was prematurely marked complete.
306
- # Revert to in_review and re-dispatch gates to review current state.
307
- LOG.info "[Basecamp:HealthCheck] Task ##{card_number} marked complete but PR ##{effective_pr} is still OPEN — reverting to in_review" if defined?(LOG)
308
-
309
- task["status"] = "in_review"
310
- task.delete("completed_at")
311
- task["gate_approvals"] = []
312
- task["changes_requested_by"] = []
313
- task["gates_dispatched_at"] = Time.now.iso8601
314
- Hooks.send(:save_epic_state, epic)
315
-
316
- # Uncheck the Basecamp todo (it was incorrectly checked)
317
- Orchestrator.send(:uncheck_todo, epic, card_number) if Orchestrator.respond_to?(:uncheck_todo, true)
318
-
319
- # Re-dispatch review gates
320
- if ReviewGate.enabled? && repo_path
321
- Thread.new do
322
- ReviewGate.dispatch_gates(
323
- epic: epic,
324
- task: task,
325
- pr_number: effective_pr,
326
- repo_name: github_repo,
327
- repo_path: repo_path
328
- )
329
- rescue StandardError => e
330
- LOG.error "[Basecamp:HealthCheck] Gate dispatch failed for ##{card_number}: #{e.message}" if defined?(LOG)
331
- end
332
- end
333
- healed_any = true
334
- end
335
- end
336
- end
337
-
338
- # If we healed anything, check if we can dispatch more tasks
339
- if healed_any
340
- Orchestrator.send(:dispatch_unblocked_tasks, epic)
105
+ if tasks.any? && tasks.all? { |task| TaskState.in?(task, :complete) }
106
+ tasks.each { |task| Orchestrator.send(:mark_todo_complete, epic, task["fizzy_card"]) }
107
+ Orchestrator.send(:complete_epic, epic)
341
108
  Orchestrator.send(:save_epic, epic)
109
+ return true
342
110
  end
343
- end
344
111
 
345
- private
112
+ # Reconcile every task in this pass. `Enumerable#any?` would stop at
113
+ # the first repaired task and defer later repairs to the next sweep.
114
+ changed = tasks.map do |task|
115
+ reconcile_task(epic, task, triggered_by: triggered_by)
116
+ end.any?
346
117
 
347
- # Check if a PR has commits newer than the most recent changes_requested review.
348
- # This detects when the impl agent pushed fixes but gates weren't re-triggered.
349
- #
350
- # @param task [Hash] Task state with pr_number
351
- # @param repo_path [String] Local repo path for gh CLI
352
- # @return [Boolean] true if PR has commits after the last changes_requested review
353
- def pr_has_newer_commits_than_reviews?(task, repo_path:)
354
- pr_number = task["pr_number"]
355
- return false unless pr_number&.positive?
356
-
357
- # Get the last changes_requested review timestamp and the latest commit timestamp
358
- stdout, _, status = Open3.capture3(
359
- "gh", "pr", "view", pr_number.to_s,
360
- "--json", "reviews,commits",
361
- "--jq", "[(.reviews | map(select(.state == \"CHANGES_REQUESTED\")) | sort_by(.submittedAt) | last | .submittedAt), (.commits | last | .committedDate)] | @tsv",
362
- chdir: repo_path
363
- )
364
- return false unless status.success? && !stdout.strip.empty?
365
-
366
- last_changes_at, last_commit_at = stdout.strip.split("\t")
367
- return false if last_changes_at.nil? || last_changes_at.empty? || last_commit_at.nil? || last_commit_at.empty?
368
-
369
- # Compare timestamps — if the latest commit is newer than the last changes_requested review,
370
- # the impl agent has pushed fixes that haven't been reviewed yet.
371
- Time.parse(last_commit_at) > Time.parse(last_changes_at)
372
- rescue StandardError => e
373
- LOG.warn "[Basecamp:HealthCheck] Error checking PR commits vs reviews: #{e.message}" if defined?(LOG)
374
- false
118
+ Orchestrator.send(:dispatch_unblocked_tasks, epic) if changed
119
+ Orchestrator.send(:save_epic, epic) if changed
120
+ changed
375
121
  end
376
122
 
377
- # Resume active epics on server startup.
378
- # For each epic, checks task states and takes appropriate action.
379
- def resume_active_epics(epics)
380
- epics.each do |epic|
381
- LOG.info "[Basecamp] Resuming epic: #{epic['title']}" if defined?(LOG)
382
-
383
- # Check if all tasks are complete — finalize the epic
384
- if epic["tasks"]&.all? { |t| t["status"] == "complete" }
385
- LOG.info "[Basecamp] Resume: all tasks complete — finalizing epic" if defined?(LOG)
386
-
387
- # Mark each Basecamp todo as complete (in case they weren't marked during normal flow)
388
- epic["tasks"].each do |task|
389
- Orchestrator.send(:mark_todo_complete, epic, task["fizzy_card"])
390
- end
391
-
392
- Orchestrator.send(:complete_epic, epic)
393
- Orchestrator.send(:save_epic, epic)
394
- next
395
- end
396
-
397
- epic["tasks"]&.each do |task|
398
- card_number = task["fizzy_card"]
399
- status = task["status"]
400
- LOG.info "[Basecamp] Resume: task ##{card_number} status=#{status}" if defined?(LOG)
401
-
402
- case status
403
- when "in_review", "in_flight"
404
- # Sync gate state from GitHub and decide next action
405
- resume_in_review_task(epic, task)
406
- when "final_decision"
407
- # Final decision was pending — check if we can merge
408
- resume_final_decision_task(epic, task)
409
- when "pending"
410
- # Check if dependencies are met and dispatch
411
- # (This is handled by dispatch_unblocked_tasks normally)
412
- next
413
- end
414
- end
123
+ private
415
124
 
416
- # Dispatch any unblocked tasks
417
- Orchestrator.send(:dispatch_unblocked_tasks, epic)
125
+ def reconcile_task(epic, task, triggered_by:)
126
+ case TaskState.state(task)
127
+ when "in_flight"
128
+ reconcile_in_flight_task(epic, task)
129
+ when "in_review"
130
+ reconcile_in_review_task(epic, task, triggered_by: triggered_by)
131
+ when "final_decision"
132
+ reconcile_final_decision_task(epic, task)
133
+ else
134
+ false
418
135
  end
419
136
  end
420
137
 
421
- def resume_in_review_task(epic, task)
138
+ def reconcile_in_flight_task(epic, task)
422
139
  card_number = task["fizzy_card"]
423
- pr_number = task["pr_number"]
424
-
425
- unless pr_number
426
- LOG.info "[Basecamp] Task ##{card_number} has no PR yet — skipping resume" if defined?(LOG)
427
- return
428
- end
140
+ # Best-effort liveness check, not atomic: the PID could exit between
141
+ # this check and the re-dispatch below. That's acceptable — if a race
142
+ # occurs, the next periodic recovery sweep (90s) will catch it.
143
+ return false if SessionRegistry.alive?("implementation-#{card_number}")
144
+ return false unless stale_dispatch?(task)
145
+
146
+ LOG.info "[Basecamp:Recovery] Re-dispatching implementation for ##{card_number}; no live session" if defined?(LOG)
147
+ task["dispatched_at"] = Time.now.iso8601
148
+ Hooks.send(:dispatch_impl_directly, epic, task)
149
+ true
150
+ end
429
151
 
430
- # Get project config for repo path
152
+ def reconcile_in_review_task(epic, task, triggered_by:)
153
+ card_number = task["fizzy_card"]
154
+ pr_number = task["pr_number"]
431
155
  project_key = task["project"]
432
- projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
433
- projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
434
- repo_path = projects.dig(project_key, "repo_path")
435
-
436
- unless repo_path
437
- LOG.warn "[Basecamp] No repo_path for project #{project_key}" if defined?(LOG)
438
- return
439
- end
156
+ return false unless pr_number && project_key && ReviewGate.enabled?
440
157
 
441
- # Sync gate approvals from GitHub
442
- sync_result = ReviewGate.sync_from_github(task, repo_path: repo_path)
443
- if sync_result[:synced]
444
- changes = sync_result[:changes] || {}
445
- if changes[:approvals_added]&.any?
446
- LOG.info "[Basecamp] Resume: synced approvals for ##{card_number}: #{changes[:approvals_added].join(', ')}" if defined?(LOG)
447
- end
448
- end
158
+ projects = projects_config
159
+ repo_path = projects.dig(project_key, "repo_path")
160
+ github_repo = projects.dig(project_key, "github_repo")
161
+ return false unless repo_path
449
162
 
450
- # Check if all gates have approved
163
+ ReviewGate.sync_from_github(task, repo_path: repo_path)
451
164
  if ReviewGate.all_gates_passed?(task)
452
- LOG.info "[Basecamp] Resume: all gates passed for ##{card_number} — dispatching final decision" if defined?(LOG)
453
- task["status"] = "final_decision"
165
+ LOG.info "[Basecamp:Recovery] All gates passed for ##{card_number}; dispatching final decision" if defined?(LOG)
166
+ TaskState.transition!(task, :approve, triggered_by: triggered_by, guard: ReviewGate.all_gates_passed?(task))
454
167
  task["awaiting_final_decision"] = true
455
- Hooks.send(:save_epic_state, epic)
456
168
  Hooks.send(:dispatch_final_decision, epic, task, {})
457
- elsif task["changes_requested_by"]&.any?
458
- # Gates requested changes — after a restart, no agent session is running even if assigned.
459
- # Always re-dispatch the impl agent to address the feedback.
460
- impl_agent = epic["agent"]
461
- LOG.info "[Basecamp] Resume: ##{card_number} has changes requested — re-dispatching #{impl_agent}" if defined?(LOG)
462
-
463
- # Transition to in_flight so the agent gets the right context
464
- task["status"] = "in_flight"
465
- task["dispatched_at"] = Time.now.iso8601
466
- Hooks.send(:save_epic_state, epic)
467
-
468
- # Spawn the agent directly — safe_assign_card won't work if already assigned
469
- Hooks.send(:dispatch_impl_directly, epic, task)
470
- else
471
- # No changes requested yet — check if gates need (re-)dispatching
472
- approvals = task["gate_approvals"]&.size || 0
473
- gates_dispatched_at = task["gates_dispatched_at"]
474
-
475
- # Calculate how many gates have responded (approved or requested changes)
476
- responded_count = approvals + (task["changes_requested_by"]&.size || 0)
477
- total_gates = ReviewGate.gates.size
478
-
479
- # Re-dispatch gates if:
480
- # 1. Gates were never dispatched (missed webhook), OR
481
- # 2. Gates were dispatched but no responses received after 5 minutes, OR
482
- # 3. Some gates responded but others didn't after 5 minutes (partial response)
483
- should_redispatch = if gates_dispatched_at.nil?
484
- true
485
- else
486
- elapsed = Time.now - Time.parse(gates_dispatched_at)
487
- elapsed > STALE_DISPATCH_TIMEOUT && responded_count < total_gates
488
- end
489
-
490
- if should_redispatch && ReviewGate.enabled?
491
- github_repo = projects.dig(project_key, "github_repo")
492
- if github_repo
493
- if responded_count.zero?
494
- # No responses at all — dispatch all gates
495
- LOG.info "[Basecamp] Resume: ##{card_number} has 0/#{total_gates} gate responses — re-dispatching all review gates" if defined?(LOG)
496
- ReviewGate.dispatch_gates(
497
- epic: epic,
498
- task: task,
499
- pr_number: pr_number,
500
- repo_name: github_repo,
501
- repo_path: repo_path
502
- )
503
- else
504
- # Partial responses — dispatch only the missing gates
505
- LOG.info "[Basecamp] Resume: ##{card_number} has #{responded_count}/#{total_gates} gate responses — re-dispatching missing gates" if defined?(LOG)
506
- ReviewGate.dispatch_missing_gates(
507
- epic: epic,
508
- task: task,
509
- pr_number: pr_number,
510
- repo_name: github_repo,
511
- repo_path: repo_path
512
- )
513
- end
514
- Hooks.send(:save_epic_state, epic)
515
- else
516
- LOG.warn "[Basecamp] Resume: ##{card_number} — cannot re-dispatch gates, no github_repo for #{project_key}" if defined?(LOG)
517
- end
518
- else
519
- LOG.info "[Basecamp] Resume: ##{card_number} has #{responded_count}/#{total_gates} gate responses, waiting for more reviews" if defined?(LOG)
520
- end
169
+ return true
170
+ end
171
+
172
+ if ReviewGate.all_gates_responded?(task) && ReviewGate.changes_requested?(task)
173
+ return recover_changes_requested_task(epic, task, triggered_by: triggered_by)
521
174
  end
175
+ return false unless github_repo
176
+
177
+ dispatched = ReviewGate.redispatch_stale_gates(
178
+ epic: epic, task: task, pr_number: pr_number,
179
+ repo_name: github_repo, repo_path: repo_path
180
+ )
181
+ dispatched.any?
522
182
  end
523
183
 
524
- def resume_final_decision_task(epic, task)
184
+ def recover_changes_requested_task(epic, task, triggered_by:)
525
185
  card_number = task["fizzy_card"]
526
- LOG.info "[Basecamp] Resume: checking final_decision task ##{card_number}, awaiting=#{task['awaiting_final_decision']}" if defined?(LOG)
186
+ # Guard: only recover if changes are actually still requested. The caller
187
+ # checks this too, but this makes the method safe to call standalone
188
+ # (e.g., if gate approvals arrive between the caller's check and here).
189
+ return false unless ReviewGate.changes_requested?(task)
190
+ return false if SessionRegistry.alive?("implementation-#{card_number}")
191
+
192
+ TaskState.transition!(task, :request_changes, triggered_by: triggered_by)
193
+ task["dispatched_at"] = Time.now.iso8601
194
+ Hooks.send(:dispatch_impl_directly, epic, task)
195
+ true
196
+ end
527
197
 
528
- # First, resolve PR number if missing or zero (branch creation may have failed)
198
+ def reconcile_final_decision_task(epic, task)
529
199
  pr_number = task["pr_number"]
530
- if (pr_number.nil? || pr_number.zero?) && task["project"]
531
- resolved_pr = resolve_pr_for_task(task)
532
- if resolved_pr
533
- task["pr_number"] = resolved_pr[:number]
534
- task["pr_repo"] = resolved_pr[:repo] if resolved_pr[:repo]
535
- pr_number = resolved_pr[:number]
536
- LOG.info "[Basecamp] Resume: resolved PR ##{pr_number} for card ##{card_number}" if defined?(LOG)
537
- end
538
- end
200
+ project_key = task["project"]
201
+ return false unless pr_number && project_key
539
202
 
540
- # Check if PR is already merged (handles manual merges, webhook misses, agent merges to wrong branch)
541
- if pr_number&.positive?
542
- project_key = task["project"]
543
- projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
544
- projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
545
- github_repo = projects.dig(project_key, "github_repo")
546
-
547
- if github_repo
548
- pr_state, = Open3.capture2("gh", "pr", "view", pr_number.to_s, "--repo", github_repo, "--json", "state", "-q", ".state")
549
- if pr_state.strip == "MERGED"
550
- LOG.info "[Basecamp] Resume: PR ##{pr_number} already merged — marking card ##{card_number} complete" if defined?(LOG)
551
- Orchestrator.on_card_completed(card_number)
552
- return
553
- end
203
+ projects = projects_config
204
+ github_repo = projects.dig(project_key, "github_repo")
205
+ if github_repo
206
+ pr_state, = Open3.capture2("gh", "pr", "view", pr_number.to_s, "--repo", github_repo, "--json", "state", "-q", ".state")
207
+ if pr_state.strip == "MERGED"
208
+ Orchestrator.on_card_completed(task["fizzy_card"])
209
+ return true
554
210
  end
555
211
  end
556
212
 
557
- # If awaiting_final_decision is set, re-dispatch
558
- # Also handle the case where it's nil but gates are all approved (stale state)
559
- if task["awaiting_final_decision"] || ReviewGate.all_gates_passed?(task)
560
- LOG.info "[Basecamp] Resume: dispatching final decision for ##{card_number}" if defined?(LOG)
561
- task["awaiting_final_decision"] = true
562
- Hooks.send(:save_epic_state, epic)
563
- Hooks.send(:dispatch_final_decision, epic, task, {})
564
- else
565
- LOG.info "[Basecamp] Resume: final_decision task ##{card_number} not ready (awaiting=#{task['awaiting_final_decision']}, gates_passed=#{ReviewGate.all_gates_passed?(task)})" if defined?(LOG)
566
- end
567
- end
568
-
569
- # Resolve a PR number for a task by searching GitHub for matching branch patterns.
570
- # Used when pr_number is 0 or nil (e.g., branch creation failed so PR was never tracked).
571
- def resolve_pr_for_task(task)
572
213
  card_number = task["fizzy_card"]
573
- project_key = task["project"]
574
- return nil unless project_key
214
+ return false if SessionRegistry.alive?("final-decision-#{card_number}")
215
+ return false unless task["awaiting_final_decision"] || ReviewGate.all_gates_passed?(task)
575
216
 
576
- projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
577
- projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
578
- repo_path = projects.dig(project_key, "repo_path")
579
- github_repo = projects.dig(project_key, "github_repo")
580
- return nil unless repo_path
581
-
582
- # Search for PR by fizzy-NNNN branch pattern (any state)
583
- stdout, _, status = Open3.capture3(
584
- "gh", "pr", "list", "--state", "all",
585
- "--json", "number,headRefName,state",
586
- "--jq", ".[] | select(.headRefName | startswith(\"fizzy-#{card_number}\")) | [.number, .state] | @tsv",
587
- chdir: repo_path
588
- )
589
- return nil unless status.success? && !stdout.strip.empty?
217
+ task["awaiting_final_decision"] = true
218
+ Hooks.send(:dispatch_final_decision, epic, task, {})
219
+ true
220
+ end
590
221
 
591
- # Take the first match
592
- number, _state = stdout.strip.split("\n").first.split("\t")
593
- return nil unless number
222
+ def stale_dispatch?(task)
223
+ dispatched_at = task["dispatched_at"]
224
+ return true unless dispatched_at
594
225
 
595
- { number: number.to_i, repo: github_repo }
596
- rescue StandardError => e
597
- LOG.warn "[Basecamp] Error resolving PR for card ##{card_number}: #{e.message}" if defined?(LOG)
598
- nil
226
+ Time.now - Time.parse(dispatched_at) > STALE_DISPATCH_TIMEOUT
227
+ rescue ArgumentError
228
+ true
229
+ end
230
+
231
+ def projects_config
232
+ projects_file = File.join(ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac")), "projects.json")
233
+ File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
599
234
  end
600
235
 
601
236
  def setup_routes(app)
@@ -635,7 +270,8 @@ module Brainiac
635
270
  bot_accounts: config["bot_accounts"].keys,
636
271
  project_mappings: config["project_mappings"].keys,
637
272
  active_epics: Orchestrator.active_epics.size,
638
- total_epics: Orchestrator.all_epics.size
273
+ total_epics: Orchestrator.all_epics.size,
274
+ sessions: SessionRegistry.status
639
275
  }.to_json
640
276
  end
641
277