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
|
@@ -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
|
|
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
|
-
#
|
|
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
|
-
|
|
63
|
+
reconcile_active_epics(active, triggered_by: "startup_recovery")
|
|
56
64
|
rescue StandardError => e
|
|
57
|
-
LOG.error "[Basecamp]
|
|
65
|
+
LOG.error "[Basecamp:Recovery] Startup reconciliation failed: #{e.message}" if defined?(LOG)
|
|
58
66
|
end
|
|
59
67
|
end
|
|
60
68
|
|
|
61
|
-
# Start
|
|
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
|
|
68
|
-
#
|
|
69
|
-
#
|
|
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
|
|
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:
|
|
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
|
-
#
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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
|
|
138
|
+
def reconcile_in_flight_task(epic, task)
|
|
422
139
|
card_number = task["fizzy_card"]
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
163
|
+
ReviewGate.sync_from_github(task, repo_path: repo_path)
|
|
451
164
|
if ReviewGate.all_gates_passed?(task)
|
|
452
|
-
LOG.info "[Basecamp]
|
|
453
|
-
task
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
|
184
|
+
def recover_changes_requested_task(epic, task, triggered_by:)
|
|
525
185
|
card_number = task["fizzy_card"]
|
|
526
|
-
|
|
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
|
-
|
|
198
|
+
def reconcile_final_decision_task(epic, task)
|
|
529
199
|
pr_number = task["pr_number"]
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
574
|
-
return
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
return
|
|
222
|
+
def stale_dispatch?(task)
|
|
223
|
+
dispatched_at = task["dispatched_at"]
|
|
224
|
+
return true unless dispatched_at
|
|
594
225
|
|
|
595
|
-
|
|
596
|
-
rescue
|
|
597
|
-
|
|
598
|
-
|
|
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
|
|