brainiac-basecamp 0.0.17 → 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.
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Brainiac
8
+ module Plugins
9
+ module Basecamp
10
+ # First-class agent session liveness tracking.
11
+ #
12
+ # Tracks active agent sessions by task_id (e.g., "gate-glados-1234",
13
+ # "final-decision-1234", "epic-review-45920028") with PID, dispatch
14
+ # timestamp, and agent name.
15
+ #
16
+ # The registry answers "is an agent actually running for this task?"
17
+ # directly via PID liveness checks — replacing the prior pattern of
18
+ # inferring liveness from Fizzy assignment or elapsed time.
19
+ #
20
+ # Persistence: Sessions are written to disk for crash recovery diagnostics,
21
+ # but the registry is treated as VOLATILE — all sessions are cleared on
22
+ # server restart (a restarted server cannot trust stale PIDs).
23
+ #
24
+ # Usage:
25
+ # SessionRegistry.register_session("gate-glados-1234", pid)
26
+ # SessionRegistry.alive?("gate-glados-1234") # => true/false (checks PID)
27
+ # SessionRegistry.mark_dead("gate-glados-1234")
28
+ # SessionRegistry.sessions_for_epic("epic-45920028")
29
+ #
30
+ module SessionRegistry
31
+ BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
32
+ SESSIONS_FILE = File.join(BRAINIAC_DIR, "basecamp_sessions.json")
33
+ IMPLEMENTATION_SESSION_PREFIX = "implementation-"
34
+
35
+ class << self
36
+ # Register an active agent session.
37
+ #
38
+ # @param task_id [String] Unique key for this session (e.g. "gate-glados-1234")
39
+ # @param pid [Integer] Process ID of the agent
40
+ # @param log_file [String, nil] Path to the agent's log file
41
+ # @param agent_name [String, nil] Name of the agent running
42
+ # @param epic_id [String, nil] Epic ID this session belongs to
43
+ # @param card_number [Integer, nil] Fizzy card number
44
+ # @return [Hash] The registered session record
45
+ def register_session(task_id, pid, log_file: nil, agent_name: nil, epic_id: nil, card_number: nil)
46
+ session = {
47
+ "task_id" => task_id.to_s,
48
+ "pid" => pid.to_i,
49
+ "agent_name" => agent_name,
50
+ "epic_id" => epic_id,
51
+ "card_number" => card_number&.to_i,
52
+ "log_file" => log_file,
53
+ "started_at" => Time.now.iso8601,
54
+ "status" => "active"
55
+ }
56
+
57
+ sessions[task_id.to_s] = session
58
+ persist!
59
+
60
+ LOG.info "[Basecamp:SessionRegistry] Registered session: #{task_id} (pid=#{pid}, agent=#{agent_name})" if defined?(LOG)
61
+
62
+ # Also forward to the global register_session if it exists (for waybar/UI)
63
+ if Object.respond_to?(:register_session, true) && !@suppress_global_forward
64
+ begin
65
+ Object.send(:register_session, task_id, pid, log_file: log_file, agent_name: agent_name)
66
+ rescue StandardError
67
+ # Non-critical — waybar integration is optional
68
+ end
69
+ end
70
+
71
+ session
72
+ end
73
+
74
+ # Check if a session is alive by verifying the PID is still running.
75
+ #
76
+ # @param task_id [String] Session task ID
77
+ # @return [Boolean] true if session exists and its PID is alive
78
+ def alive?(task_id)
79
+ session = sessions[task_id.to_s]
80
+ return false unless session
81
+ return false if session["status"] == "dead"
82
+
83
+ pid = session["pid"]
84
+ return false unless pid&.positive?
85
+
86
+ pid_alive?(pid)
87
+ end
88
+
89
+ # Mark a session as dead (without checking PID).
90
+ # Use when you know the agent has finished or crashed.
91
+ #
92
+ # @param task_id [String] Session task ID
93
+ # @return [Boolean] true if session existed and was marked dead
94
+ def mark_dead(task_id)
95
+ session = sessions[task_id.to_s]
96
+ return false unless session
97
+
98
+ session["status"] = "dead"
99
+ session["ended_at"] = Time.now.iso8601
100
+ persist!
101
+
102
+ LOG.info "[Basecamp:SessionRegistry] Marked dead: #{task_id} (pid=#{session['pid']})" if defined?(LOG)
103
+ true
104
+ end
105
+
106
+ # Get all sessions belonging to an epic.
107
+ #
108
+ # @param epic_id [String] Epic ID (e.g. "epic-45920028")
109
+ # @return [Array<Hash>] Session records for this epic
110
+ def sessions_for_epic(epic_id)
111
+ sessions.values.select { |s| s["epic_id"] == epic_id.to_s }
112
+ end
113
+
114
+ # Get all active (alive) sessions for an epic.
115
+ #
116
+ # @param epic_id [String] Epic ID
117
+ # @return [Array<Hash>] Active session records
118
+ def active_sessions_for_epic(epic_id)
119
+ sessions_for_epic(epic_id).select { |s| s["status"] == "active" && pid_alive?(s["pid"]) }
120
+ end
121
+
122
+ # Get session for a specific task.
123
+ #
124
+ # @param task_id [String] Task ID
125
+ # @return [Hash, nil] Session record or nil
126
+ def find_session(task_id)
127
+ sessions[task_id.to_s]
128
+ end
129
+
130
+ # Check if any session is alive for a given card number.
131
+ # Searches all sessions (gates, final decision, epic review) for this card.
132
+ #
133
+ # @param card_number [Integer] Fizzy card number
134
+ # @return [Boolean]
135
+ def any_alive_for_card?(card_number)
136
+ sessions.values.any? do |s|
137
+ s["card_number"] == card_number.to_i &&
138
+ s["status"] == "active" &&
139
+ pid_alive?(s["pid"])
140
+ end
141
+ end
142
+
143
+ # Stable task ID for the implementation agent assigned to a Fizzy card.
144
+ # Keep this distinct from gate and final-decision IDs so a live reviewer
145
+ # cannot prevent implementation work from being re-dispatched.
146
+ def implementation_session_id(card_number)
147
+ "#{IMPLEMENTATION_SESSION_PREFIX}#{card_number.to_i}"
148
+ end
149
+
150
+ def implementation_alive?(card_number)
151
+ alive?(implementation_session_id(card_number))
152
+ end
153
+
154
+ # Mirror Fizzy's real implementation-agent spawn into this registry.
155
+ # Fizzy invokes the global register_session("card-<number>", pid) immediately
156
+ # after run_agent returns; Basecamp installs a small observer around that
157
+ # method so it can attach epic metadata without duplicating Fizzy dispatch.
158
+ def track_global_implementation_session(card_key, pid, log_file: nil, agent_name: nil)
159
+ match = /\Acard-(\d+)\z/.match(card_key.to_s)
160
+ return unless match
161
+
162
+ card_number = match[1].to_i
163
+ epic = Orchestrator.find_epic_for_card(card_number)
164
+ return unless epic
165
+
166
+ register_session(
167
+ implementation_session_id(card_number), pid,
168
+ log_file: log_file,
169
+ agent_name: agent_name,
170
+ epic_id: epic["id"],
171
+ card_number: card_number
172
+ )
173
+ end
174
+
175
+ # Installs the observer once the core session helper is available. Keeping
176
+ # the wrapper here means Basecamp remains compatible with the normal Fizzy
177
+ # assignment flow, while liveness remains owned by SessionRegistry.
178
+ def install_global_registration_hook!
179
+ return if @global_registration_hook_installed
180
+ return unless Object.private_method_defined?(:register_session)
181
+
182
+ observer = Module.new do
183
+ def register_session(card_key, pid, **kwargs)
184
+ result = super
185
+ Brainiac::Plugins::Basecamp::SessionRegistry.track_global_implementation_session(
186
+ card_key,
187
+ pid,
188
+ log_file: kwargs[:log_file],
189
+ agent_name: kwargs[:agent_name]
190
+ )
191
+ result
192
+ end
193
+ end
194
+
195
+ Object.prepend(observer)
196
+ @global_registration_hook_installed = true
197
+ end
198
+
199
+ # Get all active sessions for a card number.
200
+ #
201
+ # @param card_number [Integer] Fizzy card number
202
+ # @return [Array<Hash>]
203
+ def active_sessions_for_card(card_number)
204
+ sessions.values.select do |s|
205
+ s["card_number"] == card_number.to_i &&
206
+ s["status"] == "active" &&
207
+ pid_alive?(s["pid"])
208
+ end
209
+ end
210
+
211
+ # Clear all sessions. Called on server restart.
212
+ # Marks all sessions as dead since we can't trust PIDs after restart.
213
+ #
214
+ # @return [Integer] Number of sessions cleared
215
+ def clear_all!
216
+ count = sessions.size
217
+ sessions.each_value do |s|
218
+ s["status"] = "dead"
219
+ s["ended_at"] = Time.now.iso8601
220
+ end
221
+ persist!
222
+
223
+ LOG.info "[Basecamp:SessionRegistry] Cleared #{count} session(s) on startup" if defined?(LOG) && count.positive?
224
+ count
225
+ end
226
+
227
+ # Sweep dead sessions from memory (cleanup stale entries older than threshold).
228
+ # Keeps dead sessions on disk for diagnostics but removes from active tracking.
229
+ #
230
+ # @param max_age [Integer] Maximum age in seconds for dead sessions (default: 1 hour)
231
+ # @return [Integer] Number of sessions swept
232
+ def sweep!(max_age: 3600)
233
+ now = Time.now
234
+ swept = 0
235
+
236
+ sessions.delete_if do |_task_id, session|
237
+ next false unless session["status"] == "dead"
238
+
239
+ ended_at = session["ended_at"]
240
+ # Orphaned entries (dead with no ended_at) are corrupt/abnormal — remove immediately
241
+ if ended_at.nil?
242
+ swept += 1
243
+ next true
244
+ end
245
+
246
+ age = now - Time.parse(ended_at)
247
+ if age > max_age
248
+ swept += 1
249
+ true
250
+ else
251
+ false
252
+ end
253
+ end
254
+
255
+ persist! if swept.positive?
256
+ swept
257
+ end
258
+
259
+ # Reap sessions whose PIDs are no longer alive.
260
+ # Call this periodically to detect agents that crashed without notification.
261
+ #
262
+ # @return [Array<String>] Task IDs that were reaped
263
+ def reap_dead!
264
+ reaped = []
265
+
266
+ sessions.each do |task_id, session|
267
+ next unless session["status"] == "active"
268
+
269
+ pid = session["pid"]
270
+ next unless pid&.positive?
271
+ next if pid_alive?(pid)
272
+
273
+ session["status"] = "dead"
274
+ session["ended_at"] = Time.now.iso8601
275
+ session["death_reason"] = "pid_exited"
276
+ reaped << task_id
277
+ LOG.info "[Basecamp:SessionRegistry] Reaped dead session: #{task_id} (pid=#{pid} no longer running)" if defined?(LOG)
278
+ end
279
+
280
+ persist! if reaped.any?
281
+ reaped
282
+ end
283
+
284
+ # Summary of current session state (for API/diagnostics).
285
+ #
286
+ # @return [Hash]
287
+ def status
288
+ active_count = sessions.values.count { |s| s["status"] == "active" && pid_alive?(s["pid"]) }
289
+ dead_count = sessions.values.count { |s| s["status"] == "dead" || (s["status"] == "active" && !pid_alive?(s["pid"])) }
290
+
291
+ {
292
+ "total" => sessions.size,
293
+ "active" => active_count,
294
+ "dead" => dead_count,
295
+ "sessions" => sessions.values.map do |s|
296
+ {
297
+ "task_id" => s["task_id"],
298
+ "pid" => s["pid"],
299
+ "agent_name" => s["agent_name"],
300
+ "epic_id" => s["epic_id"],
301
+ "card_number" => s["card_number"],
302
+ "status" => s["status"] == "active" && pid_alive?(s["pid"]) ? "active" : "dead",
303
+ "started_at" => s["started_at"]
304
+ }
305
+ end
306
+ }
307
+ end
308
+
309
+ # Suppress forwarding to global register_session (for testing).
310
+ # @api private
311
+ attr_writer :suppress_global_forward
312
+
313
+ # Reset internal state (for testing).
314
+ # @api private
315
+ def reset!
316
+ @sessions = {}
317
+ @suppress_global_forward = false
318
+ end
319
+
320
+ private
321
+
322
+ # In-memory session store.
323
+ def sessions
324
+ @sessions ||= {}
325
+ end
326
+
327
+ # Check if a PID is alive.
328
+ #
329
+ # @param pid [Integer] Process ID
330
+ # @return [Boolean]
331
+ def pid_alive?(pid)
332
+ return false unless pid&.positive?
333
+
334
+ Process.kill(0, pid)
335
+ true
336
+ rescue Errno::ESRCH
337
+ # No such process
338
+ false
339
+ rescue Errno::EPERM
340
+ # Process exists but we don't have permission to signal it —
341
+ # it's still alive
342
+ true
343
+ end
344
+
345
+ # Persist current sessions to disk for diagnostics.
346
+ def persist!
347
+ data = {
348
+ "sessions" => sessions,
349
+ "updated_at" => Time.now.iso8601
350
+ }
351
+
352
+ FileUtils.mkdir_p(File.dirname(SESSIONS_FILE))
353
+ File.write(SESSIONS_FILE, JSON.pretty_generate(data))
354
+ rescue StandardError => e
355
+ LOG.warn "[Basecamp:SessionRegistry] Failed to persist sessions: #{e.message}" if defined?(LOG)
356
+ end
357
+ end
358
+ end
359
+ end
360
+ end
361
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Brainiac
6
+ module Plugins
7
+ module Basecamp
8
+ # The single authority for an epic task's lifecycle. Task hashes remain
9
+ # serializable so existing persisted epics continue to work unchanged.
10
+ module TaskState
11
+ class InvalidTransition < StandardError; end
12
+
13
+ STATES = %w[pending in_flight in_review final_decision complete merge_failed].freeze
14
+ TRANSITIONS = {
15
+ dispatch: { "pending" => "in_flight" },
16
+ submit_for_review: { "pending" => "in_review", "in_flight" => "in_review" },
17
+ request_changes: { "in_review" => "in_flight" },
18
+ approve: { "in_review" => "final_decision" },
19
+ complete: {
20
+ "pending" => "complete",
21
+ "in_flight" => "complete",
22
+ "in_review" => "complete",
23
+ "final_decision" => "complete"
24
+ },
25
+ merge_failed: { "final_decision" => "merge_failed" },
26
+ retry_merge: { "merge_failed" => "final_decision" }
27
+ }.freeze
28
+
29
+ class << self
30
+ def state(task)
31
+ (task["status"] || "pending").to_s
32
+ end
33
+
34
+ def in?(task, *states)
35
+ states.flatten.map(&:to_s).include?(state(task))
36
+ end
37
+
38
+ def transition!(task, event, triggered_by:, at: Time.now, guard: true)
39
+ event = event.to_sym
40
+ from_state = state(task)
41
+ transitions = TRANSITIONS.fetch(event, {})
42
+ # Idempotent no-op: if the task is already in the target state for this
43
+ # event, silently return. This is intentional for recovery paths where a
44
+ # task may be re-processed after a restart — the audit trail already
45
+ # captured the original transition, so logging again would be noise.
46
+ return task if transitions.value?(from_state)
47
+
48
+ to_state = transitions[from_state]
49
+
50
+ raise InvalidTransition, "cannot #{event} task from #{from_state}" unless to_state
51
+ raise InvalidTransition, "guard failed for #{event} from #{from_state}" unless guard
52
+
53
+ migrate!(task, at: at)
54
+ timestamp = at.iso8601
55
+ task["status"] = to_state
56
+ task["transitions"] ||= []
57
+ task["transitions"] << {
58
+ "from_state" => from_state,
59
+ "to_state" => to_state,
60
+ "triggered_by" => triggered_by.to_s,
61
+ "timestamp" => timestamp
62
+ }
63
+ task
64
+ end
65
+
66
+ # Adds a history entry for state created before this state machine
67
+ # existed, without changing its current status.
68
+ def migrate!(task, triggered_by: "state_machine_migration", at: Time.now)
69
+ return task if task.key?("transitions")
70
+
71
+ current = state(task)
72
+ raise InvalidTransition, "unknown task state #{current}" unless STATES.include?(current)
73
+
74
+ task["transitions"] = [{
75
+ "from_state" => nil,
76
+ "to_state" => current,
77
+ "triggered_by" => triggered_by,
78
+ "timestamp" => at.iso8601
79
+ }]
80
+ task
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -3,7 +3,7 @@
3
3
  module Brainiac
4
4
  module Plugins
5
5
  module Basecamp
6
- VERSION = "0.0.17"
6
+ VERSION = "0.0.18"
7
7
  end
8
8
  end
9
9
  end
@@ -175,12 +175,10 @@ module Brainiac
175
175
  [200, { status: "noted" }.to_json]
176
176
  end
177
177
 
178
- # Handle comments on todos that are part of an epic.
179
- # Could be used for @bot commands within Basecamp comments.
180
- def handle_comment(_payload, recording)
181
- # Future: detect @bot commands in comments
182
- # e.g., "@Galen pause", "@Galen skip", "@Galen reassign to Sherlock"
183
- [200, { status: "noted" }.to_json]
178
+ # Handle comments on epic todolists or todos within epics.
179
+ # Routes to the mentioned agent, last responder, or epic's default agent.
180
+ def handle_comment(payload, recording)
181
+ CommentResponder.handle(payload, recording)
184
182
  end
185
183
  end
186
184
  end